mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-19 07:01:35 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18fb2547e4 |
@@ -16,7 +16,7 @@ No instance is running yet — start your own, isolated to this worktree:
|
|||||||
until it answers (usually ~10-15s).
|
until it answers (usually ~10-15s).
|
||||||
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
|
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
|
||||||
|
|
||||||
Note: `mcp__dokploy__*` resolves its URL from
|
Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from
|
||||||
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
|
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
|
||||||
discovered mid-session. If those tools are unavailable or point at the wrong
|
discovered mid-session. If those tools are unavailable or point at the wrong
|
||||||
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
|
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
|
||||||
|
|||||||
@@ -14,34 +14,34 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Get version from package.json
|
- name: Get version from package.json
|
||||||
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> "$GITHUB_ENV"
|
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Get latest GitHub tag
|
- name: Get latest GitHub tag
|
||||||
run: |
|
run: |
|
||||||
LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1)
|
LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1)
|
||||||
echo "LATEST_TAG=$LATEST_TAG" >> "$GITHUB_ENV"
|
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV
|
||||||
echo "$LATEST_TAG"
|
echo $LATEST_TAG
|
||||||
- name: Compare versions
|
- name: Compare versions
|
||||||
run: |
|
run: |
|
||||||
if [ "$VERSION" != "$LATEST_TAG" ]; then
|
if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then
|
||||||
VERSION_CHANGED="true"
|
VERSION_CHANGED="true"
|
||||||
else
|
else
|
||||||
VERSION_CHANGED="false"
|
VERSION_CHANGED="false"
|
||||||
fi
|
fi
|
||||||
echo "VERSION_CHANGED=$VERSION_CHANGED" >> "$GITHUB_ENV"
|
echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV
|
||||||
echo "Comparing versions:"
|
echo "Comparing versions:"
|
||||||
echo "Current version: $VERSION"
|
echo "Current version: ${{ env.VERSION }}"
|
||||||
echo "Latest tag: $LATEST_TAG"
|
echo "Latest tag: ${{ env.LATEST_TAG }}"
|
||||||
echo "Version changed: $VERSION_CHANGED"
|
echo "Version changed: $VERSION_CHANGED"
|
||||||
- name: Check if a PR already exists
|
- name: Check if a PR already exists
|
||||||
run: |
|
run: |
|
||||||
PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length')
|
PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length')
|
||||||
echo "PR_EXISTS=$PR_EXISTS" >> "$GITHUB_ENV"
|
echo "PR_EXISTS=$PR_EXISTS" >> $GITHUB_ENV
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GH_PAT }}
|
GH_TOKEN: ${{ secrets.GH_PAT }}
|
||||||
|
|
||||||
@@ -56,12 +56,12 @@ jobs:
|
|||||||
git push origin canary
|
git push origin canary
|
||||||
|
|
||||||
gh pr create \
|
gh pr create \
|
||||||
--title "🚀 Release $VERSION" \
|
--title "🚀 Release ${{ env.VERSION }}" \
|
||||||
--body "
|
--body '
|
||||||
This PR promotes changes from \`canary\` to \`main\` for version $VERSION.
|
This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}.
|
||||||
|
|
||||||
### 🔍 Changes Include:
|
### 🔍 Changes Include:
|
||||||
- Version bump to $VERSION
|
- Version bump to ${{ env.VERSION }}
|
||||||
- All changes from canary branch
|
- All changes from canary branch
|
||||||
|
|
||||||
### ✅ Pre-merge Checklist:
|
### ✅ Pre-merge Checklist:
|
||||||
@@ -69,7 +69,7 @@ jobs:
|
|||||||
- [ ] Documentation updated
|
- [ ] Documentation updated
|
||||||
- [ ] Docker images built and tested
|
- [ ] Docker images built and tested
|
||||||
|
|
||||||
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)" \
|
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)' \
|
||||||
--base main \
|
--base main \
|
||||||
--head canary \
|
--head canary \
|
||||||
--label "release" --label "automated pr" || true \
|
--label "release" --label "automated pr" || true \
|
||||||
|
|||||||
@@ -5,38 +5,33 @@ on:
|
|||||||
branches: [main, canary]
|
branches: [main, canary]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push-cloud-image:
|
build-and-push-cloud-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v3
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Set tag and version
|
- name: Set tag and version
|
||||||
id: meta-cloud
|
id: meta-cloud
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||||
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> "$GITHUB_OUTPUT"
|
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo "tags=siumauricio/cloud:canary" >> "$GITHUB_OUTPUT"
|
echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.cloud
|
file: ./Dockerfile.cloud
|
||||||
@@ -53,28 +48,26 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v3
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Set tag and version
|
- name: Set tag and version
|
||||||
id: meta-schedule
|
id: meta-schedule
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||||
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> "$GITHUB_OUTPUT"
|
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo "tags=siumauricio/schedule:canary" >> "$GITHUB_OUTPUT"
|
echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.schedule
|
file: ./Dockerfile.schedule
|
||||||
@@ -87,28 +80,26 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v3
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Set tag and version
|
- name: Set tag and version
|
||||||
id: meta-server
|
id: meta-server
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||||
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> "$GITHUB_OUTPUT"
|
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
echo "tags=siumauricio/server:canary" >> "$GITHUB_OUTPUT"
|
echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.server
|
file: ./Dockerfile.server
|
||||||
|
|||||||
@@ -8,23 +8,18 @@ on:
|
|||||||
env:
|
env:
|
||||||
IMAGE_NAME: dokploy/dokploy
|
IMAGE_NAME: dokploy/dokploy
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-amd:
|
docker-amd:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -32,15 +27,15 @@ jobs:
|
|||||||
- name: Set tag and version
|
- name: Set tag and version
|
||||||
id: meta
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||||
TAG="canary"
|
TAG="canary"
|
||||||
else
|
else
|
||||||
TAG="feature"
|
TAG="feature"
|
||||||
fi
|
fi
|
||||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
|
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Prepare env file
|
- name: Prepare env file
|
||||||
run: |
|
run: |
|
||||||
@@ -48,7 +43,7 @@ jobs:
|
|||||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
@@ -58,15 +53,13 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -75,15 +68,15 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||||
TAG="canary"
|
TAG="canary"
|
||||||
else
|
else
|
||||||
TAG="feature"
|
TAG="feature"
|
||||||
fi
|
fi
|
||||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
|
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Prepare env file
|
- name: Prepare env file
|
||||||
run: |
|
run: |
|
||||||
@@ -91,7 +84,7 @@ jobs:
|
|||||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/arm64
|
platforms: linux/arm64
|
||||||
@@ -103,22 +96,20 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Create and push manifests
|
- name: Create and push manifests
|
||||||
run: |
|
run: |
|
||||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
|
|
||||||
@@ -130,7 +121,7 @@ jobs:
|
|||||||
${IMAGE_NAME}:${TAG}-amd64 \
|
${IMAGE_NAME}:${TAG}-amd64 \
|
||||||
${IMAGE_NAME}:${TAG}-arm64
|
${IMAGE_NAME}:${TAG}-arm64
|
||||||
|
|
||||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||||
TAG="canary"
|
TAG="canary"
|
||||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||||
${IMAGE_NAME}:${TAG}-amd64 \
|
${IMAGE_NAME}:${TAG}-amd64 \
|
||||||
@@ -147,37 +138,32 @@ jobs:
|
|||||||
needs: [combine-manifests]
|
needs: [combine-manifests]
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.get_version.outputs.version }}
|
version: ${{ steps.get_version.outputs.version }}
|
||||||
npm_version: ${{ steps.get_version.outputs.npm_version }}
|
npm_version: ${{ steps.get_version.outputs.npm_version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Get version
|
- name: Get version
|
||||||
id: get_version
|
id: get_version
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "npm_version=${VERSION#v}" >> "$GITHUB_OUTPUT"
|
echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Fetch install.sh
|
- name: Fetch install.sh
|
||||||
env:
|
|
||||||
RELEASE_VERSION: ${{ steps.get_version.outputs.version }}
|
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
|
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
|
||||||
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
|
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
|
||||||
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
|
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
|
||||||
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${RELEASE_VERSION}}\""; tail -n +2 install.sh; } > install-pinned.sh
|
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
|
||||||
mv install-pinned.sh install.sh
|
mv install-pinned.sh install.sh
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v3
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.get_version.outputs.version }}
|
tag_name: ${{ steps.get_version.outputs.version }}
|
||||||
target_commitish: ${{ github.sha }}
|
target_commitish: ${{ github.sha }}
|
||||||
@@ -193,19 +179,15 @@ jobs:
|
|||||||
needs: [generate-release]
|
needs: [generate-release]
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
NPM_VERSION: ${{ needs.generate-release.outputs.npm_version }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10.22.0
|
version: 10.22.0
|
||||||
|
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24.4.0
|
node-version: 24.4.0
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
@@ -220,63 +202,63 @@ jobs:
|
|||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
|
||||||
cd /tmp/mcp-repo
|
cd /tmp/mcp-repo
|
||||||
|
|
||||||
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||||
mv package.json.tmp package.json
|
mv package.json.tmp package.json
|
||||||
|
|
||||||
cp $GITHUB_WORKSPACE/openapi.json src/generated/openapi.json
|
cp ${{ github.workspace }}/openapi.json src/generated/openapi.json
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run generate
|
pnpm run generate
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
git config user.name "Dokploy Bot"
|
||||||
git config user.email "bot@dokploy.com"
|
git config user.email "bot@dokploy.com"
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "chore: bump version to $NPM_VERSION" \
|
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
||||||
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||||
--allow-empty
|
--allow-empty
|
||||||
git push
|
git push
|
||||||
|
|
||||||
echo "✅ MCP repo synced to version $NPM_VERSION"
|
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||||
|
|
||||||
- name: Sync version to CLI repository
|
- name: Sync version to CLI repository
|
||||||
run: |
|
run: |
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
|
||||||
cd /tmp/cli-repo
|
cd /tmp/cli-repo
|
||||||
|
|
||||||
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||||
mv package.json.tmp package.json
|
mv package.json.tmp package.json
|
||||||
|
|
||||||
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
|
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run generate
|
pnpm run generate
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
git config user.name "Dokploy Bot"
|
||||||
git config user.email "bot@dokploy.com"
|
git config user.email "bot@dokploy.com"
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "chore: bump version to $NPM_VERSION" \
|
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
||||||
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||||
--allow-empty
|
--allow-empty
|
||||||
git push
|
git push
|
||||||
|
|
||||||
echo "✅ CLI repo synced to version $NPM_VERSION"
|
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||||
|
|
||||||
- name: Sync version to SDK repository
|
- name: Sync version to SDK repository
|
||||||
run: |
|
run: |
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
|
||||||
cd /tmp/sdk-repo
|
cd /tmp/sdk-repo
|
||||||
|
|
||||||
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||||
mv package.json.tmp package.json
|
mv package.json.tmp package.json
|
||||||
|
|
||||||
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
|
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run generate
|
pnpm run generate
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
git config user.name "Dokploy Bot"
|
||||||
git config user.email "bot@dokploy.com"
|
git config user.email "bot@dokploy.com"
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "chore: bump version to $NPM_VERSION" \
|
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
||||||
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||||
--allow-empty
|
--allow-empty
|
||||||
git push
|
git push
|
||||||
|
|
||||||
echo "✅ SDK repo synced to version $NPM_VERSION"
|
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||||
|
|||||||
@@ -6,20 +6,15 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [canary]
|
branches: [canary]
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
format:
|
format:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v5
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Setup biomeJs
|
- name: Setup biomeJs
|
||||||
uses: biomejs/setup-biome@v2
|
uses: biomejs/setup-biome@v2
|
||||||
|
|
||||||
- name: Run Biome formatter
|
- name: Run Biome formatter
|
||||||
run: biome format --write
|
run: biome format --write
|
||||||
|
|||||||
@@ -16,19 +16,17 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout main
|
- name: Checkout main
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
||||||
|
|
||||||
- name: Cherry-pick fix to main
|
- name: Cherry-pick fix to main
|
||||||
env:
|
|
||||||
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
|
|
||||||
run: |
|
run: |
|
||||||
git config user.name "Dokploy Bot"
|
git config user.name "Dokploy Bot"
|
||||||
git config user.email "bot@dokploy.com"
|
git config user.email "bot@dokploy.com"
|
||||||
SHA="$MERGE_SHA"
|
SHA="${{ github.event.pull_request.merge_commit_sha }}"
|
||||||
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
|
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
|
||||||
git cherry-pick -x -m 1 "$SHA"
|
git cherry-pick -x -m 1 "$SHA"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -7,16 +7,12 @@ concurrency:
|
|||||||
group: hotfix-to-main
|
group: hotfix-to-main
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
# Push to main goes through HOTFIX_PUSH_TOKEN, not the workflow token.
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout main
|
- name: Checkout main
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
||||||
|
|||||||
@@ -7,23 +7,18 @@ on:
|
|||||||
env:
|
env:
|
||||||
IMAGE_NAME: dokploy/monitoring
|
IMAGE_NAME: dokploy/monitoring
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-amd:
|
docker-amd:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -31,17 +26,17 @@ jobs:
|
|||||||
- name: Set tag
|
- name: Set tag
|
||||||
id: meta
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||||
TAG="canary"
|
TAG="canary"
|
||||||
else
|
else
|
||||||
TAG="feature"
|
TAG="feature"
|
||||||
fi
|
fi
|
||||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
|
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.monitoring
|
file: ./Dockerfile.monitoring
|
||||||
@@ -52,15 +47,13 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -68,17 +61,17 @@ jobs:
|
|||||||
- name: Set
|
- name: Set
|
||||||
id: meta
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||||
TAG="canary"
|
TAG="canary"
|
||||||
else
|
else
|
||||||
TAG="feature"
|
TAG="feature"
|
||||||
fi
|
fi
|
||||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
|
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.monitoring
|
file: ./Dockerfile.monitoring
|
||||||
@@ -91,29 +84,27 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Create and push manifests
|
- name: Create and push manifests
|
||||||
run: |
|
run: |
|
||||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
|
|
||||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||||
${IMAGE_NAME}:${TAG}-amd64 \
|
${IMAGE_NAME}:${TAG}-amd64 \
|
||||||
${IMAGE_NAME}:${TAG}-arm64
|
${IMAGE_NAME}:${TAG}-arm64
|
||||||
|
|
||||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||||
TAG="canary"
|
TAG="canary"
|
||||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||||
${IMAGE_NAME}:${TAG}-amd64 \
|
${IMAGE_NAME}:${TAG}-amd64 \
|
||||||
|
|||||||
@@ -14,11 +14,9 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
job: [build, test, typecheck]
|
job: [build, test, typecheck]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v5
|
||||||
with:
|
- uses: pnpm/action-setup@v5
|
||||||
persist-credentials: false
|
- uses: actions/setup-node@v5
|
||||||
- uses: pnpm/action-setup@v6
|
|
||||||
- uses: actions/setup-node@v7
|
|
||||||
with:
|
with:
|
||||||
node-version: 24.4.0
|
node-version: 24.4.0
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|||||||
@@ -12,20 +12,17 @@ on:
|
|||||||
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
generate-and-commit:
|
generate-and-commit:
|
||||||
name: Generate OpenAPI and commit to Dokploy repo
|
name: Generate OpenAPI and commit to Dokploy repo
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Dokploy repository
|
- name: Checkout Dokploy repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24.4.0
|
node-version: 24.4.0
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|||||||
@@ -28,9 +28,6 @@ env:
|
|||||||
DOKPLOY_SERVICE: dokploy
|
DOKPLOY_SERVICE: dokploy
|
||||||
DOKPLOY_PORT: 3000
|
DOKPLOY_PORT: 3000
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
@@ -424,10 +421,8 @@ jobs:
|
|||||||
# ── Job summary ────────────────────────────────────────────────────────
|
# ── Job summary ────────────────────────────────────────────────────────
|
||||||
- name: Write job summary
|
- name: Write job summary
|
||||||
if: always()
|
if: always()
|
||||||
env:
|
|
||||||
JOB_STATUS: ${{ job.status }}
|
|
||||||
run: |
|
run: |
|
||||||
STATUS="$JOB_STATUS"
|
STATUS="${{ job.status }}"
|
||||||
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
|
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
|
||||||
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
|
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
|
||||||
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
|
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
|
||||||
|
|||||||
+2
-2
@@ -123,13 +123,13 @@ pnpm run docker:push
|
|||||||
In the case you lost your password, you can reset the owner's password using the following command
|
In the case you lost your password, you can reset the owner's password using the following command
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm --filter=dokploy run reset-password
|
pnpm run reset-password
|
||||||
```
|
```
|
||||||
|
|
||||||
To reset the password of a specific user instead, pass their email as an argument
|
To reset the password of a specific user instead, pass their email as an argument
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm --filter=dokploy run reset-password user@example.com
|
pnpm run reset-password -- user@example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Both commands print the new randomly generated password to the console.
|
Both commands print the new randomly generated password to the console.
|
||||||
|
|||||||
@@ -6,13 +6,3 @@ npm run dev
|
|||||||
```
|
```
|
||||||
open http://localhost:3000
|
open http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
## Inngest
|
|
||||||
|
|
||||||
This service uses Inngest to queue deployments. Run the local dev server in a separate terminal:
|
|
||||||
|
|
||||||
```
|
|
||||||
npm run dev:inngest
|
|
||||||
```
|
|
||||||
|
|
||||||
It syncs with the app at `http://localhost:4000/api/inngest` and serves its dashboard at `http://localhost:8288`.
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "PORT=4000 tsx watch src/index.ts",
|
"dev": "PORT=4000 tsx watch src/index.ts",
|
||||||
"dev:inngest": "npx --yes inngest-cli@1.9.0 dev -u http://localhost:4000/api/inngest --no-discovery",
|
|
||||||
"build": "rimraf dist && tsc --project tsconfig.json",
|
"build": "rimraf dist && tsc --project tsconfig.json",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const mockUpdateApplication = vi.hoisted(() => vi.fn());
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/services/permission", () => {
|
|
||||||
return {
|
|
||||||
checkServicePermissionAndAccess: vi.fn(async () => undefined),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/index", () => ({
|
|
||||||
IS_CLOUD: true,
|
|
||||||
hasValidLicense: vi.fn(async () => false),
|
|
||||||
updateApplication: mockUpdateApplication,
|
|
||||||
findApplicationById: vi.fn(async () => ({
|
|
||||||
applicationId: "app-1",
|
|
||||||
appName: "example-app",
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/lib/auth", () => ({
|
|
||||||
validateRequest: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/server/api/utils/audit", () => ({
|
|
||||||
audit: vi.fn(async () => undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { applicationRouter } = await import("@/server/api/routers/application");
|
|
||||||
|
|
||||||
const caller = applicationRouter.createCaller({
|
|
||||||
session: { activeOrganizationId: "org-1" },
|
|
||||||
user: { id: "user-1", email: "user@example.com", role: "owner" },
|
|
||||||
} as Parameters<typeof applicationRouter.createCaller>[0]);
|
|
||||||
|
|
||||||
const providerChanges = [
|
|
||||||
{
|
|
||||||
name: "GitHub",
|
|
||||||
sourceType: "github",
|
|
||||||
save: () =>
|
|
||||||
caller.saveGithubProvider({
|
|
||||||
applicationId: "app-1",
|
|
||||||
repository: "example-repo",
|
|
||||||
owner: "example-owner",
|
|
||||||
branch: "main",
|
|
||||||
buildPath: "/",
|
|
||||||
githubId: "provider-1",
|
|
||||||
watchPaths: ["src/**"],
|
|
||||||
triggerType: "push",
|
|
||||||
enableSubmodules: false,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "GitLab",
|
|
||||||
sourceType: "gitlab",
|
|
||||||
save: () =>
|
|
||||||
caller.saveGitlabProvider({
|
|
||||||
applicationId: "app-1",
|
|
||||||
gitlabRepository: "example-repo",
|
|
||||||
gitlabOwner: "example-owner",
|
|
||||||
gitlabBranch: "main",
|
|
||||||
gitlabBuildPath: "/",
|
|
||||||
gitlabId: "provider-1",
|
|
||||||
gitlabProjectId: 1,
|
|
||||||
gitlabPathNamespace: "example-owner/example-repo",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Bitbucket",
|
|
||||||
sourceType: "bitbucket",
|
|
||||||
save: () =>
|
|
||||||
caller.saveBitbucketProvider({
|
|
||||||
applicationId: "app-1",
|
|
||||||
bitbucketRepository: "example-repo",
|
|
||||||
bitbucketRepositorySlug: "example-repo",
|
|
||||||
bitbucketOwner: "example-owner",
|
|
||||||
bitbucketBranch: "main",
|
|
||||||
bitbucketBuildPath: "/",
|
|
||||||
bitbucketId: "provider-1",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Gitea",
|
|
||||||
sourceType: "gitea",
|
|
||||||
save: () =>
|
|
||||||
caller.saveGiteaProvider({
|
|
||||||
applicationId: "app-1",
|
|
||||||
giteaRepository: "example-repo",
|
|
||||||
giteaOwner: "example-owner",
|
|
||||||
giteaBranch: "main",
|
|
||||||
giteaBuildPath: "/",
|
|
||||||
giteaId: "provider-1",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Docker",
|
|
||||||
sourceType: "docker",
|
|
||||||
save: () =>
|
|
||||||
caller.saveDockerProvider({
|
|
||||||
applicationId: "app-1",
|
|
||||||
dockerImage: "nginx:alpine",
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
registryUrl: "",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "custom Git",
|
|
||||||
sourceType: "git",
|
|
||||||
save: () =>
|
|
||||||
caller.saveGitProvider({
|
|
||||||
applicationId: "app-1",
|
|
||||||
customGitUrl: "https://example.com/repo.git",
|
|
||||||
customGitBranch: "main",
|
|
||||||
customGitBuildPath: "/",
|
|
||||||
customGitSSHKeyId: "key-1",
|
|
||||||
watchPaths: ["src/**"],
|
|
||||||
enableSubmodules: false,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "disconnect Git provider",
|
|
||||||
sourceType: "github",
|
|
||||||
save: () => caller.disconnectGitProvider({ applicationId: "app-1" }),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("saving an application provider", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(providerChanges)(
|
|
||||||
"keeps the status when $name settings change",
|
|
||||||
async ({ save, sourceType }) => {
|
|
||||||
for (const status of ["done", "running"]) {
|
|
||||||
mockUpdateApplication.mockClear();
|
|
||||||
let savedStatus = status;
|
|
||||||
mockUpdateApplication.mockImplementation(async (_id, update) => {
|
|
||||||
if (update.applicationStatus !== undefined) {
|
|
||||||
savedStatus = update.applicationStatus;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await save();
|
|
||||||
|
|
||||||
expect(savedStatus).toBe(status);
|
|
||||||
expect(mockUpdateApplication).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockUpdateApplication).toHaveBeenCalledWith(
|
|
||||||
"app-1",
|
|
||||||
expect.objectContaining({ sourceType }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { Destination } from "@dokploy/server/services/destination";
|
|
||||||
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
|
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
|
||||||
import { getS3Credentials } from "@dokploy/server/utils/backups/utils";
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
describe("redactRcloneCredentials (#4621)", () => {
|
describe("redactRcloneCredentials (#4621)", () => {
|
||||||
@@ -50,48 +48,3 @@ describe("redactRcloneCredentials (#4621)", () => {
|
|||||||
expect(redacted).toContain("[REDACTED]");
|
expect(redacted).toContain("[REDACTED]");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("redactRcloneCredentials with getS3Credentials output (#5519)", () => {
|
|
||||||
const buildCommand = (accessKey: string, secretAccessKey: string) =>
|
|
||||||
`rclone rcat ${getS3Credentials({
|
|
||||||
accessKey,
|
|
||||||
secretAccessKey,
|
|
||||||
region: "us-west-001",
|
|
||||||
endpoint: "https://s3.us-west-001.backblazeb2.com",
|
|
||||||
provider: "Other",
|
|
||||||
} as Destination).join(" ")} :s3:bucket/file.gz`;
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
[
|
|
||||||
"plain alphanumeric",
|
|
||||||
"001aaaabbbbccccdd0000000001",
|
|
||||||
"K001FAKEfakeFAKEfake",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"containing slashes",
|
|
||||||
"AKIAIOSFODNN7EXAMPLE",
|
|
||||||
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
|
||||||
],
|
|
||||||
["containing spaces", "key with space", "secret with space"],
|
|
||||||
["containing quotes", "it's-a-key", `say "hi" it's`],
|
|
||||||
["containing shell metacharacters", "key$HOME", "sec;ret`id`&|\\x"],
|
|
||||||
])("redacts credentials %s", (_, accessKey, secretAccessKey) => {
|
|
||||||
const redacted = redactRcloneCredentials(
|
|
||||||
buildCommand(accessKey, secretAccessKey),
|
|
||||||
);
|
|
||||||
expect(redacted).toContain(
|
|
||||||
'--s3-access-key-id="[REDACTED]" --s3-secret-access-key="[REDACTED]" --s3-region=us-west-001',
|
|
||||||
);
|
|
||||||
expect(redacted).not.toContain(accessKey);
|
|
||||||
expect(redacted).not.toContain(secretAccessKey);
|
|
||||||
expect(redacted).toContain(":s3:bucket/file.gz");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("redacts credentials embedded in an error string", () => {
|
|
||||||
const errorStr = `Error: Command failed: ${buildCommand("MYKEY", "MY/SECRET")}`;
|
|
||||||
const redacted = redactRcloneCredentials(errorStr);
|
|
||||||
expect(redacted).not.toContain("MYKEY");
|
|
||||||
expect(redacted).not.toContain("MY/SECRET");
|
|
||||||
expect(redacted).toContain("[REDACTED]");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
|
|
||||||
describe("Remote Server Action Tooltips", () => {
|
|
||||||
it("should close the tooltip when the dialog trigger is clicked", () => {
|
|
||||||
// Note: The UI layer in this project is not currently tested with DOM simulation tools
|
|
||||||
// like React Testing Library or JSDOM.
|
|
||||||
//
|
|
||||||
// The structural fix implemented (passing TooltipTrigger asChild -> DialogTrigger asChild)
|
|
||||||
// inherently guarantees that Radix UI's composeEventHandlers properly propagates the
|
|
||||||
// onPointerDown event to dismiss the tooltip simultaneously as the dialog opens.
|
|
||||||
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { createCommand } from "@dokploy/server/utils/builders/compose";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
const base = {
|
|
||||||
composeType: "docker-compose" as const,
|
|
||||||
appName: "compose-app",
|
|
||||||
sourceType: "github" as const,
|
|
||||||
composePath: "./docker-compose.yml",
|
|
||||||
command: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("compose createCommand --pull always", () => {
|
|
||||||
it("adds --pull always when pullImages is enabled", () => {
|
|
||||||
const cmd = createCommand({ ...base, pullImages: true } as any);
|
|
||||||
|
|
||||||
expect(cmd).toContain("up -d --build --remove-orphans --pull always");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits --pull when pullImages is disabled", () => {
|
|
||||||
const cmd = createCommand({ ...base, pullImages: false } as any);
|
|
||||||
|
|
||||||
expect(cmd).not.toContain("--pull");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not add --pull to stack deploy", () => {
|
|
||||||
const cmd = createCommand({
|
|
||||||
...base,
|
|
||||||
composeType: "stack",
|
|
||||||
pullImages: true,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(cmd).not.toContain("--pull");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves a custom command untouched", () => {
|
|
||||||
const cmd = createCommand({
|
|
||||||
...base,
|
|
||||||
command: "compose -p compose-app up -d",
|
|
||||||
pullImages: true,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(cmd).toBe("compose -p compose-app up -d");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -54,16 +54,7 @@ const inputEncoding: Record<string, string> = {
|
|||||||
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
|
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasDocker = () => {
|
describe("getCreateEnvFileCommand", () => {
|
||||||
try {
|
|
||||||
execFileSync("docker", ["info"], { stdio: "ignore" });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
describe.skipIf(!hasDocker())("getCreateEnvFileCommand", () => {
|
|
||||||
it("writes special environment values that Docker Compose reads back literally", () => {
|
it("writes special environment values that Docker Compose reads back literally", () => {
|
||||||
mkdirSync(codePath, { recursive: true });
|
mkdirSync(codePath, { recursive: true });
|
||||||
|
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
import type { Compose, ComposeSpecification } from "@dokploy/server";
|
|
||||||
import {
|
|
||||||
addAppNameToPreventCollision,
|
|
||||||
addSuffixToAllProperties,
|
|
||||||
} from "@dokploy/server";
|
|
||||||
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
|
|
||||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
|
||||||
import { expect, test, vi } from "vitest";
|
|
||||||
import { stringify } from "yaml";
|
|
||||||
import composeSpec from "../../../components/shared/compose-spec.json";
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<
|
|
||||||
typeof import("@dokploy/server/utils/process/execAsync")
|
|
||||||
>()),
|
|
||||||
execAsyncRemote: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const modelCompose = {
|
|
||||||
services: {
|
|
||||||
app: {
|
|
||||||
image: "my-chat-app",
|
|
||||||
models: ["llm"],
|
|
||||||
},
|
|
||||||
worker: {
|
|
||||||
image: "my-worker",
|
|
||||||
models: {
|
|
||||||
llm: {
|
|
||||||
endpoint_var: "LLM_URL",
|
|
||||||
model_var: "LLM_MODEL",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
models: {
|
|
||||||
llm: {
|
|
||||||
model: "ai/smollm2",
|
|
||||||
context_size: 2048,
|
|
||||||
runtime_flags: ["--verbose"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies ComposeSpecification;
|
|
||||||
|
|
||||||
test("bundled compose schema declares models", () => {
|
|
||||||
expect(composeSpec.properties).toHaveProperty("models");
|
|
||||||
expect(composeSpec.definitions.model.required).toEqual(["model"]);
|
|
||||||
expect(composeSpec.definitions.service.properties).toHaveProperty("models");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("suffixing does not rename model identifiers or drop model config", () => {
|
|
||||||
const updated = addSuffixToAllProperties(
|
|
||||||
structuredClone(modelCompose),
|
|
||||||
"testhash",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(updated.models?.llm?.model).toBe("ai/smollm2");
|
|
||||||
expect(updated.models?.llm?.context_size).toBe(2048);
|
|
||||||
expect(updated.models?.llm?.runtime_flags).toEqual(["--verbose"]);
|
|
||||||
expect(updated.services?.["app-testhash"]?.models).toEqual(["llm"]);
|
|
||||||
expect(updated.services?.["worker-testhash"]?.models).toEqual({
|
|
||||||
llm: {
|
|
||||||
endpoint_var: "LLM_URL",
|
|
||||||
model_var: "LLM_MODEL",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(updated.services).not.toHaveProperty("app");
|
|
||||||
expect(updated.models).not.toHaveProperty("llm-testhash");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("isolated deployment preserves model identifiers", () => {
|
|
||||||
const updated = addAppNameToPreventCollision(
|
|
||||||
structuredClone(modelCompose),
|
|
||||||
"chat-app",
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(updated.models?.llm?.model).toBe("ai/smollm2");
|
|
||||||
expect(updated.services?.app?.models).toEqual(["llm"]);
|
|
||||||
expect(updated.services?.worker?.models).toEqual({
|
|
||||||
llm: {
|
|
||||||
endpoint_var: "LLM_URL",
|
|
||||||
model_var: "LLM_MODEL",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("raw remote compose conversion preserves models", async () => {
|
|
||||||
const converted = await addDomainToCompose(
|
|
||||||
{
|
|
||||||
appName: "chat-app",
|
|
||||||
composeFile: stringify(modelCompose, { lineWidth: 1000 }),
|
|
||||||
composePath: "./docker-compose.yml",
|
|
||||||
composeType: "docker-compose",
|
|
||||||
isolatedDeployment: false,
|
|
||||||
isolatedDeploymentsVolume: false,
|
|
||||||
randomize: false,
|
|
||||||
serverId: "remote-server",
|
|
||||||
sourceType: "raw",
|
|
||||||
suffix: "",
|
|
||||||
} as unknown as Compose,
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(execAsyncRemote).not.toHaveBeenCalled();
|
|
||||||
expect(converted?.models?.llm?.model).toBe("ai/smollm2");
|
|
||||||
expect(converted?.services?.app?.models).toEqual(["llm"]);
|
|
||||||
expect(converted?.services?.worker?.models).toEqual({
|
|
||||||
llm: {
|
|
||||||
endpoint_var: "LLM_URL",
|
|
||||||
model_var: "LLM_MODEL",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const mockUpdateCompose = vi.hoisted(() => vi.fn());
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/services/permission", () => ({
|
|
||||||
checkServicePermissionAndAccess: vi.fn(async () => undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server", () => ({
|
|
||||||
IS_CLOUD: true,
|
|
||||||
findComposeById: vi.fn(async () => ({
|
|
||||||
composeId: "compose-1",
|
|
||||||
name: "example-compose",
|
|
||||||
})),
|
|
||||||
updateCompose: mockUpdateCompose,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/lib/auth", () => ({
|
|
||||||
validateRequest: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/server/api/utils/audit", () => ({
|
|
||||||
audit: vi.fn(async () => undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/server/queues/queueSetup", () => ({
|
|
||||||
cleanQueuesByCompose: vi.fn(),
|
|
||||||
killDockerBuild: vi.fn(),
|
|
||||||
myQueue: {
|
|
||||||
add: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { composeRouter } = await import("@/server/api/routers/compose");
|
|
||||||
|
|
||||||
const caller = composeRouter.createCaller({
|
|
||||||
session: { activeOrganizationId: "org-1" },
|
|
||||||
user: { id: "user-1", email: "user@example.com", role: "owner" },
|
|
||||||
} as Parameters<typeof composeRouter.createCaller>[0]);
|
|
||||||
|
|
||||||
describe("saving a compose provider", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(["done", "running"])(
|
|
||||||
"keeps the %s status when disconnecting a Git provider",
|
|
||||||
async (status) => {
|
|
||||||
let savedStatus = status;
|
|
||||||
mockUpdateCompose.mockImplementation(async (_id, update) => {
|
|
||||||
if (update.composeStatus !== undefined) {
|
|
||||||
savedStatus = update.composeStatus;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await caller.disconnectGitProvider({ composeId: "compose-1" });
|
|
||||||
|
|
||||||
expect(savedStatus).toBe(status);
|
|
||||||
expect(mockUpdateCompose).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockUpdateCompose).toHaveBeenCalledWith(
|
|
||||||
"compose-1",
|
|
||||||
expect.objectContaining({ sourceType: "github" }),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -323,11 +323,9 @@ describe("cloudflareClient.upsertRecord", () => {
|
|||||||
expect(createInit.method).toBe("POST");
|
expect(createInit.method).toBe("POST");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates the existing record when content matches", async () => {
|
it("updates the existing record instead of creating a duplicate", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }]))
|
||||||
cfSuccess([{ id: "existing-1", type: "A", content: "5.6.7.8" }]),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce(cfSuccess({ id: "existing-1" }));
|
.mockResolvedValueOnce(cfSuccess({ id: "existing-1" }));
|
||||||
|
|
||||||
const result = await cloudflareClient.upsertRecord(config, {
|
const result = await cloudflareClient.upsertRecord(config, {
|
||||||
@@ -346,25 +344,6 @@ describe("cloudflareClient.upsertRecord", () => {
|
|||||||
expect(updateInit.method).toBe("PUT");
|
expect(updateInit.method).toBe("PUT");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a new record when content differs from existing", async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
cfSuccess([{ id: "existing-1", type: "A", content: "1.1.1.1" }]),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce(cfSuccess({ id: "new-2" }));
|
|
||||||
|
|
||||||
const result = await cloudflareClient.upsertRecord(config, {
|
|
||||||
zoneId: "zone-1",
|
|
||||||
type: "A",
|
|
||||||
name: "app.example.com",
|
|
||||||
content: "5.6.7.8",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ id: "new-2" });
|
|
||||||
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
|
|
||||||
expect(createInit.method).toBe("POST");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults ttl to 1 (automatic) when not provided", async () => {
|
it("defaults ttl to 1 (automatic) when not provided", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce(cfSuccess([]))
|
.mockResolvedValueOnce(cfSuccess([]))
|
||||||
|
|||||||
@@ -1,486 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const mockFetch = vi.fn();
|
|
||||||
global.fetch = mockFetch as typeof fetch;
|
|
||||||
|
|
||||||
import { infomaniakClient } from "@dokploy/server/utils/dns/infomaniak";
|
|
||||||
|
|
||||||
const jsonResponse = (body: unknown, ok = true, status = 200) =>
|
|
||||||
({
|
|
||||||
ok,
|
|
||||||
status,
|
|
||||||
json: async () => body,
|
|
||||||
}) as Response;
|
|
||||||
|
|
||||||
const ikSuccess = (data: unknown) => jsonResponse({ result: "success", data });
|
|
||||||
|
|
||||||
const ikPage = (data: unknown, page: number, pages: number) =>
|
|
||||||
jsonResponse({ result: "success", data, page, pages });
|
|
||||||
|
|
||||||
const ikError = (description: string, status = 400) =>
|
|
||||||
jsonResponse(
|
|
||||||
{ result: "error", error: { code: "not_authorized", description } },
|
|
||||||
false,
|
|
||||||
status,
|
|
||||||
);
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
providerType: "infomaniak" as const,
|
|
||||||
apiToken: "ik_test_token",
|
|
||||||
};
|
|
||||||
|
|
||||||
const lastCall = () =>
|
|
||||||
mockFetch.mock.calls.at(-1) as [string, RequestInit & { method?: string }];
|
|
||||||
|
|
||||||
const lastBody = () => JSON.parse(lastCall()[1].body as string);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockFetch.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("infomaniakClient.listZones", () => {
|
|
||||||
it("exposes each domain product as a zone keyed by its name", async () => {
|
|
||||||
mockFetch.mockResolvedValue(
|
|
||||||
ikPage(
|
|
||||||
[
|
|
||||||
{ id: 1, customer_name: "example.com" },
|
|
||||||
{ id: 2, customer_name: "example.ch" },
|
|
||||||
],
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const zones = await infomaniakClient.listZones(config);
|
|
||||||
|
|
||||||
expect(zones).toEqual([
|
|
||||||
{ id: "example.com", name: "example.com" },
|
|
||||||
{ id: "example.ch", name: "example.ch" },
|
|
||||||
]);
|
|
||||||
const [url, init] = lastCall();
|
|
||||||
// The documented endpoint is the plural one; the singular is legacy and
|
|
||||||
// returns no pagination metadata at all.
|
|
||||||
expect(url).toContain("/1/products?service_name=domain");
|
|
||||||
expect(url).toContain("page=1");
|
|
||||||
expect(init.headers).toMatchObject({
|
|
||||||
Authorization: "Bearer ik_test_token",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("walks every page so accounts with many domains keep all their zones", async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(ikPage([{ id: 1, customer_name: "a.com" }], 1, 3))
|
|
||||||
.mockResolvedValueOnce(ikPage([{ id: 2, customer_name: "b.com" }], 2, 3))
|
|
||||||
.mockResolvedValueOnce(ikPage([{ id: 3, customer_name: "c.com" }], 3, 3));
|
|
||||||
|
|
||||||
const zones = await infomaniakClient.listZones(config);
|
|
||||||
|
|
||||||
expect(zones.map((zone) => zone.name)).toEqual(["a.com", "b.com", "c.com"]);
|
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
||||||
expect((mockFetch.mock.calls[2] as [string])[0]).toContain("page=3");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stops after a single page when the response has no pagination", async () => {
|
|
||||||
mockFetch.mockResolvedValue(ikSuccess([{ id: 1, customer_name: "a.com" }]));
|
|
||||||
|
|
||||||
const zones = await infomaniakClient.listZones(config);
|
|
||||||
|
|
||||||
expect(zones).toEqual([{ id: "a.com", name: "a.com" }]);
|
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("propagates the API error description", async () => {
|
|
||||||
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
|
|
||||||
|
|
||||||
await expect(infomaniakClient.listZones(config)).rejects.toThrow(
|
|
||||||
"Authorization required",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("infomaniakClient.listRecords", () => {
|
|
||||||
it("rebuilds the fqdn from the relative source", async () => {
|
|
||||||
mockFetch.mockResolvedValue(
|
|
||||||
ikSuccess([
|
|
||||||
{ id: 10, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
|
|
||||||
{ id: 11, type: "A", source: "", target: "5.6.7.8", ttl: 600 },
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const records = await infomaniakClient.listRecords(config, "example.com");
|
|
||||||
|
|
||||||
expect(records).toEqual([
|
|
||||||
{
|
|
||||||
id: "10",
|
|
||||||
type: "A",
|
|
||||||
name: "app.example.com",
|
|
||||||
content: "1.2.3.4",
|
|
||||||
ttl: 300,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "11",
|
|
||||||
type: "A",
|
|
||||||
name: "example.com",
|
|
||||||
content: "5.6.7.8",
|
|
||||||
ttl: 600,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(lastCall()[0]).toBe(
|
|
||||||
"https://api.infomaniak.com/2/zones/example.com/records?with=records_description",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([".", "", "@"])("treats a %s source as the apex", async (source) => {
|
|
||||||
mockFetch.mockResolvedValue(
|
|
||||||
ikSuccess([{ id: 12, type: "A", source, target: "1.2.3.4", ttl: 300 }]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const records = await infomaniakClient.listRecords(config, "example.com");
|
|
||||||
|
|
||||||
expect(records[0]?.name).toBe("example.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("unquotes TXT targets", async () => {
|
|
||||||
mockFetch.mockResolvedValue(
|
|
||||||
ikSuccess([
|
|
||||||
{
|
|
||||||
id: 13,
|
|
||||||
type: "TXT",
|
|
||||||
source: "_acme-challenge",
|
|
||||||
target: '"token-value"',
|
|
||||||
ttl: 300,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const records = await infomaniakClient.listRecords(config, "example.com");
|
|
||||||
|
|
||||||
expect(records[0]?.content).toBe("token-value");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves a CAA target untouched", async () => {
|
|
||||||
mockFetch.mockResolvedValue(
|
|
||||||
ikSuccess([
|
|
||||||
{
|
|
||||||
id: 14,
|
|
||||||
type: "CAA",
|
|
||||||
source: "",
|
|
||||||
target: '0 issue "letsencrypt.org"',
|
|
||||||
ttl: 300,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const records = await infomaniakClient.listRecords(config, "example.com");
|
|
||||||
|
|
||||||
expect(records[0]?.content).toBe('0 issue "letsencrypt.org"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("infomaniakClient.upsertRecord", () => {
|
|
||||||
it("creates the record when no matching source and type exists", async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(ikSuccess([]))
|
|
||||||
.mockResolvedValueOnce(ikSuccess({ id: 42 }));
|
|
||||||
|
|
||||||
const result = await infomaniakClient.upsertRecord(config, {
|
|
||||||
zoneId: "example.com",
|
|
||||||
type: "A",
|
|
||||||
name: "app.example.com",
|
|
||||||
content: "1.2.3.4",
|
|
||||||
ttl: 600,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ id: "42" });
|
|
||||||
const [url, init] = lastCall();
|
|
||||||
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
|
|
||||||
expect(init.method).toBe("POST");
|
|
||||||
expect(lastBody()).toEqual({
|
|
||||||
type: "A",
|
|
||||||
source: "app",
|
|
||||||
target: "1.2.3.4",
|
|
||||||
ttl: 600,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates the existing record when content matches", async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
ikSuccess([
|
|
||||||
{ id: 7, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce(ikSuccess({ id: 7 }));
|
|
||||||
|
|
||||||
const result = await infomaniakClient.upsertRecord(config, {
|
|
||||||
zoneId: "example.com",
|
|
||||||
type: "A",
|
|
||||||
name: "app.example.com",
|
|
||||||
content: "1.2.3.4",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ id: "7" });
|
|
||||||
const [url, init] = lastCall();
|
|
||||||
expect(url).toBe(
|
|
||||||
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
|
||||||
);
|
|
||||||
expect(init.method).toBe("PUT");
|
|
||||||
expect(lastBody().ttl).toBe(300);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a new record when content differs from existing", async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
ikSuccess([
|
|
||||||
{ id: 7, type: "A", source: "app", target: "1.1.1.1", ttl: 300 },
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce(ikSuccess({ id: 50 }));
|
|
||||||
|
|
||||||
const result = await infomaniakClient.upsertRecord(config, {
|
|
||||||
zoneId: "example.com",
|
|
||||||
type: "A",
|
|
||||||
name: "app.example.com",
|
|
||||||
content: "1.2.3.4",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ id: "50" });
|
|
||||||
const [url, init] = lastCall();
|
|
||||||
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
|
|
||||||
expect(init.method).toBe("POST");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes a root dot as the source for an apex record and strips the trailing dot", async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(ikSuccess([]))
|
|
||||||
.mockResolvedValueOnce(ikSuccess({ id: 43 }));
|
|
||||||
|
|
||||||
await infomaniakClient.upsertRecord(config, {
|
|
||||||
zoneId: "example.com",
|
|
||||||
type: "A",
|
|
||||||
name: "example.com.",
|
|
||||||
content: "1.2.3.4",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(lastBody().source).toBe(".");
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([".", "", "@"])(
|
|
||||||
"matches an existing apex record stored with a %s source",
|
|
||||||
async (source) => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
ikSuccess([
|
|
||||||
{ id: 8, type: "A", source, target: "1.2.3.4", ttl: 3600 },
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
|
|
||||||
|
|
||||||
const result = await infomaniakClient.upsertRecord(config, {
|
|
||||||
zoneId: "example.com",
|
|
||||||
type: "A",
|
|
||||||
name: "example.com",
|
|
||||||
content: "1.2.3.4",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ id: "8" });
|
|
||||||
expect(lastCall()[1].method).toBe("PUT");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("matches the existing apex record instead of creating a duplicate", async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
ikSuccess([
|
|
||||||
{
|
|
||||||
id: 8,
|
|
||||||
type: "TXT",
|
|
||||||
source: ".",
|
|
||||||
target: '"v=spf1 -all"',
|
|
||||||
ttl: 3600,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
|
|
||||||
|
|
||||||
const result = await infomaniakClient.upsertRecord(config, {
|
|
||||||
zoneId: "example.com",
|
|
||||||
type: "TXT",
|
|
||||||
name: "example.com",
|
|
||||||
content: "v=spf1 -all",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ id: "8" });
|
|
||||||
const [url, init] = lastCall();
|
|
||||||
expect(init.method).toBe("PUT");
|
|
||||||
expect(url).toBe(
|
|
||||||
"https://api.infomaniak.com/2/zones/example.com/records/8",
|
|
||||||
);
|
|
||||||
expect(lastBody().target).toBe('"v=spf1 -all"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("quotes a TXT target on write", async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(ikSuccess([]))
|
|
||||||
.mockResolvedValueOnce(ikSuccess({ id: 44 }));
|
|
||||||
|
|
||||||
await infomaniakClient.upsertRecord(config, {
|
|
||||||
zoneId: "example.com",
|
|
||||||
type: "TXT",
|
|
||||||
name: "_acme-challenge.example.com",
|
|
||||||
content: "token-value",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(lastBody().target).toBe('"token-value"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not double-quote a TXT target that is already quoted", async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(ikSuccess([]))
|
|
||||||
.mockResolvedValueOnce(ikSuccess({ id: 45 }));
|
|
||||||
|
|
||||||
await infomaniakClient.upsertRecord(config, {
|
|
||||||
zoneId: "example.com",
|
|
||||||
type: "TXT",
|
|
||||||
name: "_acme-challenge.example.com",
|
|
||||||
content: '"token-value"',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(lastBody().target).toBe('"token-value"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("queries the API with a source and type filter instead of the whole zone", async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(ikSuccess([]))
|
|
||||||
.mockResolvedValueOnce(ikSuccess({ id: 50 }));
|
|
||||||
|
|
||||||
await infomaniakClient.upsertRecord(config, {
|
|
||||||
zoneId: "example.com",
|
|
||||||
type: "A",
|
|
||||||
name: "app.example.com",
|
|
||||||
content: "1.2.3.4",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [url] = mockFetch.mock.calls[0] as [string];
|
|
||||||
expect(url).toContain("filter%5Bsource%5D=app");
|
|
||||||
expect(url).toContain("filter%5Btypes%5D%5B%5D=A");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores a partial filter hit rather than overwriting a different record", async () => {
|
|
||||||
// filter[source] matches substrings: asking for "auto" also returns
|
|
||||||
// "autoconfig" and "autodiscover". Trusting it would overwrite one of them.
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
ikSuccess([
|
|
||||||
{
|
|
||||||
id: 61,
|
|
||||||
type: "CNAME",
|
|
||||||
source: "autoconfig",
|
|
||||||
target: "a.example.net",
|
|
||||||
ttl: 300,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 62,
|
|
||||||
type: "CNAME",
|
|
||||||
source: "autodiscover",
|
|
||||||
target: "b.example.net",
|
|
||||||
ttl: 300,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce(ikSuccess({ id: 63 }));
|
|
||||||
|
|
||||||
const result = await infomaniakClient.upsertRecord(config, {
|
|
||||||
zoneId: "example.com",
|
|
||||||
type: "CNAME",
|
|
||||||
name: "auto.example.com",
|
|
||||||
content: "c.example.net",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ id: "63" });
|
|
||||||
expect(lastCall()[1].method).toBe("POST");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("infomaniakClient.updateRecord", () => {
|
|
||||||
it("updates the record and keeps its id", async () => {
|
|
||||||
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
|
|
||||||
|
|
||||||
const result = await infomaniakClient.updateRecord(
|
|
||||||
config,
|
|
||||||
"example.com",
|
|
||||||
"7",
|
|
||||||
{
|
|
||||||
type: "CNAME",
|
|
||||||
name: "www.example.com",
|
|
||||||
content: "example.com",
|
|
||||||
ttl: 900,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toEqual({ id: "7" });
|
|
||||||
const [url, init] = lastCall();
|
|
||||||
expect(url).toBe(
|
|
||||||
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
|
||||||
);
|
|
||||||
expect(init.method).toBe("PUT");
|
|
||||||
expect(lastBody()).toEqual({
|
|
||||||
type: "CNAME",
|
|
||||||
source: "www",
|
|
||||||
target: "example.com",
|
|
||||||
ttl: 900,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the default ttl when none is provided", async () => {
|
|
||||||
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
|
|
||||||
|
|
||||||
await infomaniakClient.updateRecord(config, "example.com", "7", {
|
|
||||||
type: "A",
|
|
||||||
name: "app.example.com",
|
|
||||||
content: "1.2.3.4",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(lastBody().ttl).toBe(300);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("infomaniakClient.deleteRecord", () => {
|
|
||||||
it("deletes the record", async () => {
|
|
||||||
mockFetch.mockResolvedValue(ikSuccess(null));
|
|
||||||
|
|
||||||
await infomaniakClient.deleteRecord(config, "example.com", "7");
|
|
||||||
|
|
||||||
const [url, init] = lastCall();
|
|
||||||
expect(url).toBe(
|
|
||||||
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
|
||||||
);
|
|
||||||
expect(init.method).toBe("DELETE");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("propagates a delete failure", async () => {
|
|
||||||
mockFetch.mockResolvedValue(ikError("Record not found", 404));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
infomaniakClient.deleteRecord(config, "example.com", "7"),
|
|
||||||
).rejects.toThrow("Record not found");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("infomaniakClient.testConnection", () => {
|
|
||||||
it("resolves when the domain listing succeeds", async () => {
|
|
||||||
mockFetch.mockResolvedValue(ikSuccess([]));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
infomaniakClient.testConnection(config),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects on an invalid token", async () => {
|
|
||||||
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
|
|
||||||
|
|
||||||
await expect(infomaniakClient.testConnection(config)).rejects.toThrow(
|
|
||||||
"Infomaniak: request to /1/products?service_name=domain&per_page=1 failed: Authorization required",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,581 +0,0 @@
|
|||||||
import { createHash } from "node:crypto";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const mockFetch = vi.fn();
|
|
||||||
global.fetch = mockFetch as typeof fetch;
|
|
||||||
|
|
||||||
import { ovhClient } from "@dokploy/server/utils/dns/ovh";
|
|
||||||
|
|
||||||
const textResponse = (body: string, ok = true, status = 200) =>
|
|
||||||
({
|
|
||||||
ok,
|
|
||||||
status,
|
|
||||||
text: async () => body,
|
|
||||||
}) as Response;
|
|
||||||
|
|
||||||
const ovhSuccess = (data: unknown) =>
|
|
||||||
textResponse(data === undefined ? "" : JSON.stringify(data));
|
|
||||||
|
|
||||||
const ovhError = (message: string, status = 403) =>
|
|
||||||
textResponse(JSON.stringify({ message }), false, status);
|
|
||||||
|
|
||||||
const SERVER_TIME = 1788268225;
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
providerType: "ovh" as const,
|
|
||||||
endpoint: "ovh-eu" as const,
|
|
||||||
applicationKey: "app-key",
|
|
||||||
applicationSecret: "app-secret",
|
|
||||||
consumerKey: "consumer-key",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Each test uses a distinct endpoint so the module-level clock-skew cache, which
|
|
||||||
// is keyed by base url, never leaks a measurement between them.
|
|
||||||
let endpointCursor = 0;
|
|
||||||
const endpoints = [
|
|
||||||
"ovh-eu",
|
|
||||||
"ovh-ca",
|
|
||||||
"ovh-us",
|
|
||||||
"kimsufi-eu",
|
|
||||||
"kimsufi-ca",
|
|
||||||
"soyoustart-eu",
|
|
||||||
"soyoustart-ca",
|
|
||||||
] as const;
|
|
||||||
const baseUrls: Record<(typeof endpoints)[number], string> = {
|
|
||||||
"ovh-eu": "https://eu.api.ovh.com/1.0",
|
|
||||||
"ovh-ca": "https://ca.api.ovh.com/1.0",
|
|
||||||
"ovh-us": "https://api.us.ovhcloud.com/1.0",
|
|
||||||
"kimsufi-eu": "https://eu.api.kimsufi.com/1.0",
|
|
||||||
"kimsufi-ca": "https://ca.api.kimsufi.com/1.0",
|
|
||||||
"soyoustart-eu": "https://eu.api.soyoustart.com/1.0",
|
|
||||||
"soyoustart-ca": "https://ca.api.soyoustart.com/1.0",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** A config on a not-yet-used endpoint, so the first call always fetches /auth/time. */
|
|
||||||
const freshConfig = () => {
|
|
||||||
const endpoint = endpoints[
|
|
||||||
endpointCursor % endpoints.length
|
|
||||||
] as (typeof endpoints)[number];
|
|
||||||
endpointCursor += 1;
|
|
||||||
return { ...config, endpoint, baseUrl: baseUrls[endpoint] };
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Replies to /auth/time, then to each queued API response in order. */
|
|
||||||
const mockApi = (...responses: Response[]) => {
|
|
||||||
let call = 0;
|
|
||||||
mockFetch.mockImplementation((url: string) => {
|
|
||||||
if (url.endsWith("/auth/time")) {
|
|
||||||
return Promise.resolve(textResponse(String(SERVER_TIME)));
|
|
||||||
}
|
|
||||||
const response = responses[call];
|
|
||||||
call += 1;
|
|
||||||
return Promise.resolve(response ?? ovhSuccess(null));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const apiCalls = () =>
|
|
||||||
mockFetch.mock.calls.filter(
|
|
||||||
([url]) => !(url as string).endsWith("/auth/time"),
|
|
||||||
) as [string, RequestInit][];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockFetch.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ovhClient request signing", () => {
|
|
||||||
it("signs the request with the API server clock, not the local one", async () => {
|
|
||||||
const { baseUrl, ...cfg } = freshConfig();
|
|
||||||
mockApi(ovhSuccess(["example.com"]));
|
|
||||||
vi.spyOn(Date, "now").mockReturnValue((SERVER_TIME - 120) * 1000);
|
|
||||||
|
|
||||||
await ovhClient.listZones(cfg);
|
|
||||||
|
|
||||||
const [url, init] = apiCalls()[0] as [string, RequestInit];
|
|
||||||
const headers = init.headers as Record<string, string>;
|
|
||||||
expect(url).toBe(`${baseUrl}/domain/zone`);
|
|
||||||
expect(headers["X-Ovh-Timestamp"]).toBe(String(SERVER_TIME));
|
|
||||||
expect(headers["X-Ovh-Application"]).toBe("app-key");
|
|
||||||
expect(headers["X-Ovh-Consumer"]).toBe("consumer-key");
|
|
||||||
|
|
||||||
const expected = createHash("sha1")
|
|
||||||
.update(
|
|
||||||
["app-secret", "consumer-key", "GET", url, "", SERVER_TIME].join("+"),
|
|
||||||
)
|
|
||||||
.digest("hex");
|
|
||||||
expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
|
|
||||||
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("signs a request body when one is sent", async () => {
|
|
||||||
const { baseUrl, ...cfg } = freshConfig();
|
|
||||||
mockApi(ovhSuccess([]), ovhSuccess({ id: 5 }), ovhSuccess(null));
|
|
||||||
vi.spyOn(Date, "now").mockReturnValue(SERVER_TIME * 1000);
|
|
||||||
|
|
||||||
await ovhClient.upsertRecord(cfg, {
|
|
||||||
zoneId: "example.com",
|
|
||||||
type: "A",
|
|
||||||
name: "app.example.com",
|
|
||||||
content: "1.2.3.4",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [url, init] = apiCalls()[1] as [string, RequestInit];
|
|
||||||
const headers = init.headers as Record<string, string>;
|
|
||||||
const body = init.body as string;
|
|
||||||
expect(body).not.toBe("");
|
|
||||||
const expected = createHash("sha1")
|
|
||||||
.update(
|
|
||||||
["app-secret", "consumer-key", "POST", url, body, SERVER_TIME].join(
|
|
||||||
"+",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.digest("hex");
|
|
||||||
expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
|
|
||||||
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ovhClient.listZones", () => {
|
|
||||||
it("maps each zone name to a zone", async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
mockApi(ovhSuccess(["example.com", "example.fr"]));
|
|
||||||
|
|
||||||
const zones = await ovhClient.listZones(cfg);
|
|
||||||
|
|
||||||
expect(zones).toEqual([
|
|
||||||
{ id: "example.com", name: "example.com" },
|
|
||||||
{ id: "example.fr", name: "example.fr" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("names the missing root right when OVH refuses the zone listing", async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
mockApi(ovhError("This call has not been granted", 403));
|
|
||||||
|
|
||||||
// A `GET /domain/zone/*` rule does not cover the bare `GET /domain/zone`,
|
|
||||||
// so the raw OVH message would send users looking in the wrong place.
|
|
||||||
await expect(ovhClient.listZones(cfg)).rejects.toThrow(
|
|
||||||
/missing the `GET \/domain\/zone` right/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("propagates the API error message", async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
mockApi(ovhError("Invalid signature", 403));
|
|
||||||
|
|
||||||
await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ovhClient.listRecords", () => {
|
|
||||||
it("resolves each id into a full record", async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
mockApi(
|
|
||||||
ovhSuccess([1, 2]),
|
|
||||||
ovhSuccess({
|
|
||||||
id: 1,
|
|
||||||
zone: "example.com",
|
|
||||||
fieldType: "A",
|
|
||||||
subDomain: "app",
|
|
||||||
target: "1.2.3.4",
|
|
||||||
ttl: 600,
|
|
||||||
}),
|
|
||||||
ovhSuccess({
|
|
||||||
id: 2,
|
|
||||||
zone: "example.com",
|
|
||||||
fieldType: "A",
|
|
||||||
subDomain: null,
|
|
||||||
target: "5.6.7.8",
|
|
||||||
ttl: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const records = await ovhClient.listRecords(cfg, "example.com");
|
|
||||||
|
|
||||||
expect(records).toEqual([
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
type: "A",
|
|
||||||
name: "app.example.com",
|
|
||||||
content: "1.2.3.4",
|
|
||||||
ttl: 600,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
type: "A",
|
|
||||||
name: "example.com",
|
|
||||||
content: "5.6.7.8",
|
|
||||||
ttl: 0,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the records in the order of the returned ids", async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
const record = (id: number, subDomain: string) => ({
|
|
||||||
id,
|
|
||||||
zone: "example.com",
|
|
||||||
fieldType: "A",
|
|
||||||
subDomain,
|
|
||||||
target: `10.0.0.${id}`,
|
|
||||||
ttl: 60,
|
|
||||||
});
|
|
||||||
mockApi(
|
|
||||||
ovhSuccess([1, 2, 3, 4, 5]),
|
|
||||||
...[1, 2, 3, 4, 5].map((id) => ovhSuccess(record(id, `host${id}`))),
|
|
||||||
);
|
|
||||||
|
|
||||||
const records = await ovhClient.listRecords(cfg, "example.com");
|
|
||||||
|
|
||||||
expect(records.map((r) => r.id)).toEqual(["1", "2", "3", "4", "5"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ovhClient.upsertRecord", () => {
|
|
||||||
it("creates the record then refreshes the zone", async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
|
|
||||||
|
|
||||||
const result = await ovhClient.upsertRecord(cfg, {
|
|
||||||
zoneId: "example.com",
|
|
||||||
type: "A",
|
|
||||||
name: "app.example.com",
|
|
||||||
content: "1.2.3.4",
|
|
||||||
ttl: 600,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ id: "9" });
|
|
||||||
const calls = apiCalls();
|
|
||||||
expect(calls[0]?.[0]).toContain(
|
|
||||||
"/domain/zone/example.com/record?fieldType=A&subDomain=app",
|
|
||||||
);
|
|
||||||
expect(calls[1]?.[1].method).toBe("POST");
|
|
||||||
expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
|
|
||||||
fieldType: "A",
|
|
||||||
subDomain: "app",
|
|
||||||
target: "1.2.3.4",
|
|
||||||
ttl: 600,
|
|
||||||
});
|
|
||||||
expect(calls[2]?.[0]).toContain("/domain/zone/example.com/refresh");
|
|
||||||
expect(calls[2]?.[1].method).toBe("POST");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates the existing record when content matches", async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
mockApi(
|
|
||||||
ovhSuccess([4]),
|
|
||||||
ovhSuccess({
|
|
||||||
id: 4,
|
|
||||||
zone: "example.com",
|
|
||||||
fieldType: "A",
|
|
||||||
subDomain: "app",
|
|
||||||
target: "1.2.3.4",
|
|
||||||
ttl: 60,
|
|
||||||
}),
|
|
||||||
ovhSuccess(null),
|
|
||||||
ovhSuccess(null),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await ovhClient.upsertRecord(cfg, {
|
|
||||||
zoneId: "example.com",
|
|
||||||
type: "A",
|
|
||||||
name: "app.example.com",
|
|
||||||
content: "1.2.3.4",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ id: "4" });
|
|
||||||
const calls = apiCalls();
|
|
||||||
expect(calls[2]?.[0]).toContain("/domain/zone/example.com/record/4");
|
|
||||||
expect(calls[2]?.[1].method).toBe("PUT");
|
|
||||||
expect(calls[3]?.[0]).toContain("/refresh");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a new record when content differs from existing", async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
mockApi(
|
|
||||||
ovhSuccess([4]),
|
|
||||||
ovhSuccess({
|
|
||||||
id: 4,
|
|
||||||
zone: "example.com",
|
|
||||||
fieldType: "A",
|
|
||||||
subDomain: "app",
|
|
||||||
target: "1.1.1.1",
|
|
||||||
ttl: 60,
|
|
||||||
}),
|
|
||||||
ovhSuccess({ id: 10 }),
|
|
||||||
ovhSuccess(null),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await ovhClient.upsertRecord(cfg, {
|
|
||||||
zoneId: "example.com",
|
|
||||||
type: "A",
|
|
||||||
name: "app.example.com",
|
|
||||||
content: "5.6.7.8",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ id: "10" });
|
|
||||||
const calls = apiCalls();
|
|
||||||
expect(calls[2]?.[1].method).toBe("POST");
|
|
||||||
expect(calls[3]?.[0]).toContain("/refresh");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits the ttl so OVH applies the zone default", async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
|
|
||||||
|
|
||||||
await ovhClient.upsertRecord(cfg, {
|
|
||||||
zoneId: "example.com",
|
|
||||||
type: "A",
|
|
||||||
name: "app.example.com",
|
|
||||||
content: "1.2.3.4",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(JSON.parse(apiCalls()[1]?.[1].body as string)).not.toHaveProperty(
|
|
||||||
"ttl",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes an empty subDomain for the apex and strips the trailing dot", async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
|
|
||||||
|
|
||||||
await ovhClient.upsertRecord(cfg, {
|
|
||||||
zoneId: "example.com",
|
|
||||||
type: "A",
|
|
||||||
name: "example.com.",
|
|
||||||
content: "1.2.3.4",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(apiCalls()[0]?.[0]).toContain("subDomain=");
|
|
||||||
expect(JSON.parse(apiCalls()[1]?.[1].body as string).subDomain).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ovhClient.updateRecord", () => {
|
|
||||||
it("updates in place when the type is unchanged", async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
mockApi(
|
|
||||||
ovhSuccess({
|
|
||||||
id: 4,
|
|
||||||
zone: "example.com",
|
|
||||||
fieldType: "A",
|
|
||||||
subDomain: "app",
|
|
||||||
target: "1.1.1.1",
|
|
||||||
ttl: 60,
|
|
||||||
}),
|
|
||||||
ovhSuccess(null),
|
|
||||||
ovhSuccess(null),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
|
|
||||||
type: "A",
|
|
||||||
name: "app.example.com",
|
|
||||||
content: "1.2.3.4",
|
|
||||||
ttl: 300,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ id: "4" });
|
|
||||||
const calls = apiCalls();
|
|
||||||
expect(calls[1]?.[1].method).toBe("PUT");
|
|
||||||
expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
|
|
||||||
subDomain: "app",
|
|
||||||
target: "1.2.3.4",
|
|
||||||
ttl: 300,
|
|
||||||
});
|
|
||||||
expect(calls[2]?.[0]).toContain("/refresh");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("replaces the record when the type changes, since PUT carries no fieldType", async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
mockApi(
|
|
||||||
ovhSuccess({
|
|
||||||
id: 4,
|
|
||||||
zone: "example.com",
|
|
||||||
fieldType: "A",
|
|
||||||
subDomain: "app",
|
|
||||||
target: "1.1.1.1",
|
|
||||||
ttl: 60,
|
|
||||||
}),
|
|
||||||
ovhSuccess(null),
|
|
||||||
ovhSuccess({ id: 11 }),
|
|
||||||
ovhSuccess(null),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
|
|
||||||
type: "CNAME",
|
|
||||||
name: "app.example.com",
|
|
||||||
content: "example.com",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ id: "11" });
|
|
||||||
const calls = apiCalls();
|
|
||||||
expect(calls[1]?.[1].method).toBe("DELETE");
|
|
||||||
expect(calls[2]?.[1].method).toBe("POST");
|
|
||||||
expect(JSON.parse(calls[2]?.[1].body as string).fieldType).toBe("CNAME");
|
|
||||||
expect(calls[3]?.[0]).toContain("/refresh");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("restores the original record when the replacement fails", async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
const original = {
|
|
||||||
id: 4,
|
|
||||||
zone: "example.com",
|
|
||||||
fieldType: "A",
|
|
||||||
subDomain: "app",
|
|
||||||
target: "1.1.1.1",
|
|
||||||
ttl: 60,
|
|
||||||
};
|
|
||||||
mockApi(
|
|
||||||
ovhSuccess(original),
|
|
||||||
ovhSuccess(null),
|
|
||||||
ovhError("Invalid target", 400),
|
|
||||||
ovhSuccess({ id: 12 }),
|
|
||||||
ovhSuccess(null),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
ovhClient.updateRecord(cfg, "example.com", "4", {
|
|
||||||
type: "CNAME",
|
|
||||||
name: "app.example.com",
|
|
||||||
content: "not a valid target",
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("Invalid target");
|
|
||||||
|
|
||||||
const calls = apiCalls();
|
|
||||||
expect(calls[1]?.[1].method).toBe("DELETE");
|
|
||||||
expect(calls[2]?.[1].method).toBe("POST");
|
|
||||||
// The original record is put back with its own type, target and ttl.
|
|
||||||
expect(JSON.parse(calls[3]?.[1].body as string)).toEqual({
|
|
||||||
fieldType: "A",
|
|
||||||
subDomain: "app",
|
|
||||||
target: "1.1.1.1",
|
|
||||||
ttl: 60,
|
|
||||||
});
|
|
||||||
expect(calls[4]?.[0]).toContain("/refresh");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports the lost record when the restore also fails", async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
mockApi(
|
|
||||||
ovhSuccess({
|
|
||||||
id: 4,
|
|
||||||
zone: "example.com",
|
|
||||||
fieldType: "A",
|
|
||||||
subDomain: "app",
|
|
||||||
target: "1.1.1.1",
|
|
||||||
ttl: 60,
|
|
||||||
}),
|
|
||||||
ovhSuccess(null),
|
|
||||||
ovhError("Invalid target", 400),
|
|
||||||
ovhError("Service unavailable", 503),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
ovhClient.updateRecord(cfg, "example.com", "4", {
|
|
||||||
type: "CNAME",
|
|
||||||
name: "app.example.com",
|
|
||||||
content: "not a valid target",
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(
|
|
||||||
/Recreate it manually: A app\.example\.com -> 1\.1\.1\.1/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("says the change was applied when only the zone refresh fails", async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
mockApi(
|
|
||||||
ovhSuccess({
|
|
||||||
id: 4,
|
|
||||||
zone: "example.com",
|
|
||||||
fieldType: "A",
|
|
||||||
subDomain: "app",
|
|
||||||
target: "1.1.1.1",
|
|
||||||
ttl: 60,
|
|
||||||
}),
|
|
||||||
ovhSuccess(null),
|
|
||||||
ovhSuccess({ id: 11 }),
|
|
||||||
ovhError("Service unavailable", 503),
|
|
||||||
);
|
|
||||||
|
|
||||||
// The replacement succeeded, so the record exists at the provider — only
|
|
||||||
// publishing failed. Rolling back would destroy correct state.
|
|
||||||
await expect(
|
|
||||||
ovhClient.updateRecord(cfg, "example.com", "4", {
|
|
||||||
type: "CNAME",
|
|
||||||
name: "app.example.com",
|
|
||||||
content: "example.com",
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/was applied, but refreshing zone "example\.com" failed/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not tell the user to recreate a record that was restored but not published", async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
mockApi(
|
|
||||||
ovhSuccess({
|
|
||||||
id: 4,
|
|
||||||
zone: "example.com",
|
|
||||||
fieldType: "A",
|
|
||||||
subDomain: "app",
|
|
||||||
target: "1.1.1.1",
|
|
||||||
ttl: 60,
|
|
||||||
}),
|
|
||||||
ovhSuccess(null), // DELETE de l'ancien
|
|
||||||
ovhError("Invalid target", 400), // POST de remplacement -> échec
|
|
||||||
ovhSuccess({ id: 12 }), // POST de restauration -> succès
|
|
||||||
ovhError("Service unavailable", 503), // refresh -> échec
|
|
||||||
);
|
|
||||||
|
|
||||||
const attempt = ovhClient.updateRecord(cfg, "example.com", "4", {
|
|
||||||
type: "CNAME",
|
|
||||||
name: "app.example.com",
|
|
||||||
content: "not a valid target",
|
|
||||||
});
|
|
||||||
|
|
||||||
// L'enregistrement existe de nouveau chez OVH : le recréer le dupliquerait.
|
|
||||||
await expect(attempt).rejects.toThrow(/was restored, but refreshing zone/);
|
|
||||||
await expect(attempt).rejects.not.toThrow(/Recreate it manually/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ovhClient.deleteRecord", () => {
|
|
||||||
it("deletes the record then refreshes the zone", async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
mockApi(ovhSuccess(null), ovhSuccess(null));
|
|
||||||
|
|
||||||
await ovhClient.deleteRecord(cfg, "example.com", "4");
|
|
||||||
|
|
||||||
const calls = apiCalls();
|
|
||||||
expect(calls[0]?.[0]).toContain("/domain/zone/example.com/record/4");
|
|
||||||
expect(calls[0]?.[1].method).toBe("DELETE");
|
|
||||||
expect(calls[1]?.[0]).toContain("/domain/zone/example.com/refresh");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not refresh the zone when the delete fails", async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
mockApi(ovhError("This object does not exist", 404));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
ovhClient.deleteRecord(cfg, "example.com", "4"),
|
|
||||||
).rejects.toThrow("This object does not exist");
|
|
||||||
expect(apiCalls()).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ovhClient.testConnection", () => {
|
|
||||||
it("resolves when the zone listing succeeds", async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
mockApi(ovhSuccess([]));
|
|
||||||
|
|
||||||
await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects on invalid credentials", async () => {
|
|
||||||
const cfg = freshConfig();
|
|
||||||
mockApi(ovhError("Invalid signature", 403));
|
|
||||||
|
|
||||||
await expect(ovhClient.testConnection(cfg)).rejects.toThrow(
|
|
||||||
"Invalid signature",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -121,11 +121,9 @@ describe("porkbunClient.upsertRecord", () => {
|
|||||||
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/");
|
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("edits the existing record when content matches", async () => {
|
it("edits the existing record instead of creating a duplicate", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce(pbSuccess({ records: [{ id: "existing-1" }] }))
|
||||||
pbSuccess({ records: [{ id: "existing-1", content: "5.6.7.8" }] }),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce(pbSuccess({}));
|
.mockResolvedValueOnce(pbSuccess({}));
|
||||||
|
|
||||||
const result = await porkbunClient.upsertRecord(config, {
|
const result = await porkbunClient.upsertRecord(config, {
|
||||||
@@ -140,30 +138,6 @@ describe("porkbunClient.upsertRecord", () => {
|
|||||||
expect(editUrl).toContain("/dns/edit/example.com/existing-1");
|
expect(editUrl).toContain("/dns/edit/example.com/existing-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a new record when content differs from existing", async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
pbSuccess({ records: [{ id: "existing-1", content: "1.1.1.1" }] }),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce(pbSuccess({ id: "new-2" }));
|
|
||||||
|
|
||||||
const result = await porkbunClient.upsertRecord(config, {
|
|
||||||
zoneId: "example.com",
|
|
||||||
type: "A",
|
|
||||||
name: "app.example.com",
|
|
||||||
content: "5.6.7.8",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ id: "new-2" });
|
|
||||||
const [createUrl, createInit] = mockFetch.mock.calls[1] as [
|
|
||||||
string,
|
|
||||||
RequestInit,
|
|
||||||
];
|
|
||||||
expect(createUrl).toContain("/dns/create/example.com");
|
|
||||||
const body = JSON.parse(createInit.body as string);
|
|
||||||
expect(body).toMatchObject({ name: "app", type: "A", content: "5.6.7.8" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults ttl to 600 when not provided", async () => {
|
it("defaults ttl to 600 when not provided", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce(pbSuccess({ records: [] }))
|
.mockResolvedValueOnce(pbSuccess({ records: [] }))
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
import os from "node:os";
|
|
||||||
import {
|
|
||||||
getServerIpCandidates,
|
|
||||||
validateDomain,
|
|
||||||
} from "@dokploy/server/services/domain";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
execAsyncRemote: vi.fn(),
|
|
||||||
findServerById: vi.fn(),
|
|
||||||
getPublicIpWithFallback: vi.fn(),
|
|
||||||
getWebServerSettings: vi.fn(),
|
|
||||||
resolve4: vi.fn(),
|
|
||||||
resolve6: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("node:dns", () => ({
|
|
||||||
default: {
|
|
||||||
resolve4: mocks.resolve4,
|
|
||||||
resolve6: mocks.resolve6,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
|
||||||
execAsyncRemote: mocks.execAsyncRemote,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/services/server", () => ({
|
|
||||||
findServerById: mocks.findServerById,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/services/web-server-settings", () => ({
|
|
||||||
getWebServerSettings: mocks.getWebServerSettings,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/wss/utils", () => ({
|
|
||||||
getPublicIpWithFallback: mocks.getPublicIpWithFallback,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("getServerIpCandidates", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes every address reported by a multi-homed remote server", async () => {
|
|
||||||
mocks.findServerById.mockResolvedValue({
|
|
||||||
ipAddress: "10.0.0.10",
|
|
||||||
});
|
|
||||||
mocks.execAsyncRemote.mockResolvedValue({
|
|
||||||
stdout: ["10.0.0.10", "192.0.2.10", "2001:db8::10"].join("\n"),
|
|
||||||
stderr: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(getServerIpCandidates("server-id")).resolves.toEqual([
|
|
||||||
"10.0.0.10",
|
|
||||||
"192.0.2.10",
|
|
||||||
"2001:db8::10",
|
|
||||||
]);
|
|
||||||
expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
|
|
||||||
"server-id",
|
|
||||||
expect.stringContaining("ip -o addr show scope global"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes every address assigned to the local Dokploy host", async () => {
|
|
||||||
mocks.getWebServerSettings.mockResolvedValue({
|
|
||||||
serverIp: "10.0.0.10",
|
|
||||||
});
|
|
||||||
mocks.getPublicIpWithFallback.mockResolvedValue("2001:db8::10");
|
|
||||||
vi.spyOn(os, "networkInterfaces").mockReturnValue({
|
|
||||||
eth0: [
|
|
||||||
{
|
|
||||||
address: "192.0.2.10",
|
|
||||||
netmask: "255.255.255.0",
|
|
||||||
family: "IPv4",
|
|
||||||
mac: "00:00:00:00:00:00",
|
|
||||||
internal: false,
|
|
||||||
cidr: "192.0.2.10/24",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(getServerIpCandidates()).resolves.toEqual([
|
|
||||||
"10.0.0.10",
|
|
||||||
"192.0.2.10",
|
|
||||||
"2001:db8::10",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("retains remote interface addresses when public IP detection times out", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
mocks.findServerById.mockResolvedValue({
|
|
||||||
ipAddress: "10.0.0.10",
|
|
||||||
});
|
|
||||||
mocks.execAsyncRemote.mockImplementation(
|
|
||||||
(_serverId: string, command: string) => {
|
|
||||||
if (command.includes("curl")) {
|
|
||||||
return new Promise(() => undefined);
|
|
||||||
}
|
|
||||||
return Promise.resolve({
|
|
||||||
stdout: "192.0.2.10\n",
|
|
||||||
stderr: "",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const candidatesPromise = getServerIpCandidates("server-id");
|
|
||||||
await vi.advanceTimersByTimeAsync(7000);
|
|
||||||
|
|
||||||
await expect(candidatesPromise).resolves.toEqual([
|
|
||||||
"10.0.0.10",
|
|
||||||
"192.0.2.10",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("validateDomain", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("validates an IPv6-only domain against an IPv6 server address", async () => {
|
|
||||||
const noIpv4 = Object.assign(new Error("queryA ENODATA example.com"), {
|
|
||||||
code: "ENODATA",
|
|
||||||
});
|
|
||||||
mocks.resolve4.mockImplementation(
|
|
||||||
(_domain: string, callback: (error: Error | null) => void) =>
|
|
||||||
callback(noIpv4),
|
|
||||||
);
|
|
||||||
mocks.resolve6.mockImplementation(
|
|
||||||
(
|
|
||||||
_domain: string,
|
|
||||||
callback: (error: Error | null, addresses?: string[]) => void,
|
|
||||||
) => callback(null, ["2001:db8::10"]),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
validateDomain("example.com", ["2001:db8::10"]),
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
isValid: true,
|
|
||||||
resolvedIp: "2001:db8::10",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
type HasInput = { input: Record<string, unknown> };
|
|
||||||
|
|
||||||
const findMany = vi.hoisted(() => vi.fn());
|
|
||||||
|
|
||||||
const {
|
|
||||||
send,
|
|
||||||
paginate,
|
|
||||||
SSMClient,
|
|
||||||
GetParametersCommand,
|
|
||||||
DescribeParametersCommand,
|
|
||||||
} = vi.hoisted(() => {
|
|
||||||
class FakeCommand {
|
|
||||||
input: Record<string, unknown>;
|
|
||||||
constructor(input: Record<string, unknown>) {
|
|
||||||
this.input = input;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const send = vi.fn();
|
|
||||||
const paginate = vi.fn();
|
|
||||||
class SSMClient {
|
|
||||||
send(command: unknown) {
|
|
||||||
return send(command);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
send,
|
|
||||||
paginate,
|
|
||||||
SSMClient,
|
|
||||||
GetParametersCommand: class extends FakeCommand {},
|
|
||||||
DescribeParametersCommand: class extends FakeCommand {},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@aws-sdk/client-ssm", () => ({
|
|
||||||
SSMClient,
|
|
||||||
GetParametersCommand,
|
|
||||||
DescribeParametersCommand,
|
|
||||||
paginateDescribeParameters: paginate,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/db", () => ({
|
|
||||||
db: {
|
|
||||||
query: {
|
|
||||||
vaultProvider: {
|
|
||||||
findMany: (...args: unknown[]) => findMany(...args),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { resolveVaultReferences } from "@dokploy/server/utils/vault";
|
|
||||||
import { awsParameterStoreClient } from "@dokploy/server/utils/vault/aws-parameter-store";
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
providerType: "aws-parameter-store" as const,
|
|
||||||
region: "eu-central-1",
|
|
||||||
accessKeyId: "AKIA_TEST",
|
|
||||||
secretAccessKey: "secret",
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
send.mockReset();
|
|
||||||
paginate.mockReset();
|
|
||||||
findMany.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("awsParameterStoreClient", () => {
|
|
||||||
it("decrypts parameters in batches of ten and preserves selectors", async () => {
|
|
||||||
const refs = [
|
|
||||||
"/prod/database:CURRENT",
|
|
||||||
...Array.from({ length: 10 }, (_, index) => `/prod/secret-${index}`),
|
|
||||||
];
|
|
||||||
send.mockImplementation(async (command: HasInput) => ({
|
|
||||||
Parameters: (command.input.Names as string[]).map((ref) => {
|
|
||||||
if (ref === "/prod/database:CURRENT") {
|
|
||||||
return {
|
|
||||||
Name: "/prod/database",
|
|
||||||
Selector: ":CURRENT",
|
|
||||||
Value: "selected-value",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { Name: ref, Value: `value-for-${ref}` };
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const result = await awsParameterStoreClient.getSecrets(config, refs);
|
|
||||||
|
|
||||||
expect(send).toHaveBeenCalledTimes(2);
|
|
||||||
expect((send.mock.calls[0]?.[0] as HasInput).input).toMatchObject({
|
|
||||||
WithDecryption: true,
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
((send.mock.calls[0]?.[0] as HasInput).input.Names as string[]).length,
|
|
||||||
).toBe(10);
|
|
||||||
expect(
|
|
||||||
((send.mock.calls[1]?.[0] as HasInput).input.Names as string[]).length,
|
|
||||||
).toBe(1);
|
|
||||||
expect(result["/prod/database:CURRENT"]).toBe("selected-value");
|
|
||||||
expect(result["/prod/secret-9"]).toBe("value-for-/prod/secret-9");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a missing parameter without exposing other values", async () => {
|
|
||||||
send.mockResolvedValue({ Parameters: [], InvalidParameters: ["/missing"] });
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
awsParameterStoreClient.getSecrets(config, ["/missing"]),
|
|
||||||
).rejects.toThrow('AWS Parameter Store: parameter "/missing" not found');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tests the connection within the configured hierarchy", async () => {
|
|
||||||
send.mockResolvedValue({ Parameters: [] });
|
|
||||||
|
|
||||||
await awsParameterStoreClient.testConnection({
|
|
||||||
...config,
|
|
||||||
parameterPath: "/production/my-app/",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(send).toHaveBeenCalledTimes(1);
|
|
||||||
expect((send.mock.calls[0]?.[0] as HasInput).input).toEqual({
|
|
||||||
ParameterFilters: [
|
|
||||||
{
|
|
||||||
Key: "Path",
|
|
||||||
Option: "Recursive",
|
|
||||||
Values: ["/production/my-app"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
MaxResults: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("explains the discovery permission when connection testing is denied", async () => {
|
|
||||||
const error = new Error("not authorized");
|
|
||||||
error.name = "AccessDeniedException";
|
|
||||||
send.mockRejectedValue(error);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
awsParameterStoreClient.testConnection(config),
|
|
||||||
).rejects.toThrow("ssm:DescribeParameters");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lists parameter names across pages within the configured hierarchy", async () => {
|
|
||||||
paginate.mockReturnValue(
|
|
||||||
(async function* () {
|
|
||||||
yield { Parameters: [{ Name: "/prod/db" }] };
|
|
||||||
yield { Parameters: [{ Name: "/prod/api" }] };
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const names = await awsParameterStoreClient.listSecretNames?.({
|
|
||||||
...config,
|
|
||||||
parameterPath: " /production/my-app/ ",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(names).toEqual(["/prod/db", "/prod/api"]);
|
|
||||||
expect(paginate).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ pageSize: 50 }),
|
|
||||||
{
|
|
||||||
ParameterFilters: [
|
|
||||||
{
|
|
||||||
Key: "Path",
|
|
||||||
Option: "Recursive",
|
|
||||||
Values: ["/production/my-app"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves a vault reference through the registered provider", async () => {
|
|
||||||
findMany.mockResolvedValue([
|
|
||||||
{
|
|
||||||
name: "ssm-prod",
|
|
||||||
providerType: "aws-parameter-store",
|
|
||||||
config,
|
|
||||||
assignments: [{ projectId: "project-1", environmentIds: [] }],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
send.mockResolvedValue({
|
|
||||||
Parameters: [{ Name: "/prod/database-password", Value: "resolved" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await resolveVaultReferences(
|
|
||||||
"DB_PASSWORD=${{vault.ssm-prod./prod/database-password}}",
|
|
||||||
{
|
|
||||||
organizationId: "organization-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
environmentId: "environment-1",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBe("DB_PASSWORD=resolved");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-218
@@ -20,7 +20,6 @@ import {
|
|||||||
import { azureClient } from "@dokploy/server/utils/vault/azure";
|
import { azureClient } from "@dokploy/server/utils/vault/azure";
|
||||||
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
|
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
|
||||||
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
|
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
|
||||||
import { infisicalClient } from "@dokploy/server/utils/vault/infisical";
|
|
||||||
import { phaseClient } from "@dokploy/server/utils/vault/phase";
|
import { phaseClient } from "@dokploy/server/utils/vault/phase";
|
||||||
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
|
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
|
||||||
|
|
||||||
@@ -432,223 +431,6 @@ describe("azure client", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("infisical client", () => {
|
|
||||||
const config = {
|
|
||||||
providerType: "infisical" as const,
|
|
||||||
siteUrl: "https://app.infisical.com",
|
|
||||||
clientId: "client-1",
|
|
||||||
clientSecret: "client-secret",
|
|
||||||
projectId: "workspace-1",
|
|
||||||
environmentSlug: "prod",
|
|
||||||
secretPath: "/frontend",
|
|
||||||
};
|
|
||||||
|
|
||||||
const loginResponse = () => jsonResponse({ accessToken: "token-1" });
|
|
||||||
const list = (secrets: Record<string, string>) =>
|
|
||||||
jsonResponse({
|
|
||||||
secrets: Object.entries(secrets).map(([secretKey, secretValue]) => ({
|
|
||||||
secretKey,
|
|
||||||
secretValue,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
const listPathOf = (callIndex: number) => {
|
|
||||||
const [url] = mockFetch.mock.calls[callIndex] as [string];
|
|
||||||
return new URL(url).searchParams.get("secretPath");
|
|
||||||
};
|
|
||||||
|
|
||||||
it("asks the list endpoint to expand secret references", async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(loginResponse())
|
|
||||||
.mockResolvedValueOnce(list({ DB_URL: "postgres://real" }));
|
|
||||||
|
|
||||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
|
||||||
|
|
||||||
expect(result).toEqual({ DB_URL: "postgres://real" });
|
|
||||||
const [listUrl] = mockFetch.mock.calls[1] as [string];
|
|
||||||
const params = new URL(listUrl).searchParams;
|
|
||||||
expect(params.get("expandSecretReferences")).toBe("true");
|
|
||||||
expect(params.get("workspaceId")).toBe("workspace-1");
|
|
||||||
expect(params.get("environment")).toBe("prod");
|
|
||||||
expect(params.get("secretPath")).toBe("/frontend");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("asks for imported secrets and merges them", async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
|
||||||
jsonResponse({
|
|
||||||
secrets: [],
|
|
||||||
imports: [
|
|
||||||
{
|
|
||||||
secretPath: "/shared",
|
|
||||||
secrets: [
|
|
||||||
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
|
||||||
|
|
||||||
expect(result).toEqual({ DB_URL: "postgres://imported" });
|
|
||||||
const [listUrl] = mockFetch.mock.calls[1] as [string];
|
|
||||||
// snake_case on purpose: `includeImports` is silently ignored and the
|
|
||||||
// response comes back with an empty `imports` array.
|
|
||||||
expect(new URL(listUrl).searchParams.get("include_imports")).toBe("true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lets the later import win when two define the same key", async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
|
||||||
jsonResponse({
|
|
||||||
secrets: [],
|
|
||||||
imports: [
|
|
||||||
{
|
|
||||||
secretPath: "/base",
|
|
||||||
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://base" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
secretPath: "/override",
|
|
||||||
secrets: [
|
|
||||||
{ secretKey: "DB_URL", secretValue: "postgres://override" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
|
||||||
|
|
||||||
// Infisical documents this order: "If two imports carry a secret with the
|
|
||||||
// same name, the value from the bottom-most import wins." The response
|
|
||||||
// lists imports in that order, so assigning them in sequence matches it.
|
|
||||||
expect(result).toEqual({ DB_URL: "postgres://override" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lets a folder's own secret win over an imported one of the same name", async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
|
||||||
jsonResponse({
|
|
||||||
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://local" }],
|
|
||||||
imports: [
|
|
||||||
{
|
|
||||||
secretPath: "/shared",
|
|
||||||
secrets: [
|
|
||||||
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
|
||||||
|
|
||||||
expect(result).toEqual({ DB_URL: "postgres://local" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws a clear error for a missing secret", async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(loginResponse())
|
|
||||||
.mockResolvedValueOnce(jsonResponse({ secrets: [] }));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
infisicalClient.getSecrets(config, ["ABSENT"]),
|
|
||||||
).rejects.toThrow('secret "ABSENT" not found in environment "prod"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("propagates authentication failures with the status code", async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce(jsonResponse({}, false, 401));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
infisicalClient.getSecrets(config, ["DB_URL"]),
|
|
||||||
).rejects.toThrow("authentication failed (status 401)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves a relative <path>:<KEY> ref against the provider path", async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(loginResponse())
|
|
||||||
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
|
|
||||||
|
|
||||||
const result = await infisicalClient.getSecrets(config, [
|
|
||||||
"shared/sentry:SENTRY_DSN",
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
"shared/sentry:SENTRY_DSN": "https://key@sentry.io/1",
|
|
||||||
});
|
|
||||||
expect(listPathOf(1)).toBe("/frontend/shared/sentry");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a leading slash as an absolute path", async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(loginResponse())
|
|
||||||
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
|
|
||||||
|
|
||||||
await infisicalClient.getSecrets(config, ["/external/sentry:SENTRY_DSN"]);
|
|
||||||
|
|
||||||
expect(listPathOf(1)).toBe("/external/sentry");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the root path clean when the provider sits at /", async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(loginResponse())
|
|
||||||
.mockResolvedValueOnce(list({ KEY: "value" }));
|
|
||||||
|
|
||||||
await infisicalClient.getSecrets({ ...config, secretPath: "/" }, [
|
|
||||||
"external/sentry:KEY",
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(listPathOf(1)).toBe("/external/sentry");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logs in once and fetches each path once", async () => {
|
|
||||||
const byPath: Record<string, Record<string, string>> = {
|
|
||||||
"/frontend": { A: "a", B: "b" },
|
|
||||||
"/frontend/other": { C: "c" },
|
|
||||||
};
|
|
||||||
mockFetch.mockImplementation(async (url: string) => {
|
|
||||||
if (url.includes("/auth/universal-auth/login")) return loginResponse();
|
|
||||||
const path = new URL(url).searchParams.get("secretPath") as string;
|
|
||||||
return list(byPath[path] ?? {});
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await infisicalClient.getSecrets(config, [
|
|
||||||
"A",
|
|
||||||
"B",
|
|
||||||
"other:C",
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(result).toEqual({ A: "a", B: "b", "other:C": "c" });
|
|
||||||
|
|
||||||
const urls = mockFetch.mock.calls.map(([url]) => url as string);
|
|
||||||
expect(urls.filter((u) => u.includes("/login"))).toHaveLength(1);
|
|
||||||
expect(
|
|
||||||
urls
|
|
||||||
.filter((u) => u.includes("/secrets/raw?"))
|
|
||||||
.map((u) => new URL(u).searchParams.get("secretPath"))
|
|
||||||
.sort(),
|
|
||||||
).toEqual(["/frontend", "/frontend/other"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("names the path when a secret is missing from an explicit one", async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce(loginResponse())
|
|
||||||
.mockResolvedValueOnce(list({}));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
infisicalClient.getSecrets(config, ["external/sentry:ABSENT"]),
|
|
||||||
).rejects.toThrow(
|
|
||||||
'secret "ABSENT" not found at "/frontend/external/sentry"',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a ref with an empty path or key", async () => {
|
|
||||||
await expect(infisicalClient.getSecrets(config, [":KEY"])).rejects.toThrow(
|
|
||||||
"expected format <path>:<KEY>",
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
infisicalClient.getSecrets(config, ["external/sentry:"]),
|
|
||||||
).rejects.toThrow("expected format <path>:<KEY>");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("doppler client", () => {
|
describe("doppler client", () => {
|
||||||
it("propagates auth errors with the status code", async () => {
|
it("propagates auth errors with the status code", async () => {
|
||||||
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
|
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies";
|
|
||||||
|
|
||||||
describe("isHttpsRequest", () => {
|
|
||||||
it("returns true when X-Forwarded-Proto is https", () => {
|
|
||||||
expect(isHttpsRequest("https")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for http or a missing header", () => {
|
|
||||||
expect(isHttpsRequest("http")).toBe(false);
|
|
||||||
expect(isHttpsRequest(undefined)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the left-most value of a proxy chain", () => {
|
|
||||||
expect(isHttpsRequest("https, http")).toBe(true);
|
|
||||||
expect(isHttpsRequest("http, https")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles an array header value and is case-insensitive", () => {
|
|
||||||
expect(isHttpsRequest(["HTTPS", "http"])).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("secureSetCookie", () => {
|
|
||||||
it("adds Secure to a cookie that lacks it", () => {
|
|
||||||
expect(
|
|
||||||
secureSetCookie("better-auth.session_token=abc; Path=/; HttpOnly"),
|
|
||||||
).toBe("better-auth.session_token=abc; Path=/; HttpOnly; Secure");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not duplicate Secure when already present", () => {
|
|
||||||
const cookie = "better-auth.session_token=abc; Path=/; Secure; HttpOnly";
|
|
||||||
expect(secureSetCookie(cookie)).toBe(cookie);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies to every cookie in an array", () => {
|
|
||||||
expect(secureSetCookie(["a=1; Path=/", "b=2; Path=/; Secure"])).toEqual([
|
|
||||||
"a=1; Path=/; Secure",
|
|
||||||
"b=2; Path=/; Secure",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes a numeric header value through untouched", () => {
|
|
||||||
expect(secureSetCookie(123)).toBe(123);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { apiCreateLibsql } from "@dokploy/server/db/schema";
|
|
||||||
import { APP_NAME_MESSAGE } from "@dokploy/server/db/schema/utils";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
describe("apiCreateLibsql appName", () => {
|
|
||||||
const validBase = {
|
|
||||||
name: "My libSQL",
|
|
||||||
dockerImage: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
|
|
||||||
environmentId: "env-1",
|
|
||||||
description: null,
|
|
||||||
databaseUser: "admin",
|
|
||||||
databasePassword: "secretPassword1",
|
|
||||||
sqldNode: "primary" as const,
|
|
||||||
sqldPrimaryUrl: null,
|
|
||||||
enableNamespaces: false,
|
|
||||||
serverId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
["a space", "my libsql"],
|
|
||||||
["an at sign", "repro@lib"],
|
|
||||||
["a slash", "team/libsql"],
|
|
||||||
["a colon", "libsql:1"],
|
|
||||||
])("refuses an appName with %s", (_, appName) => {
|
|
||||||
const result = apiCreateLibsql.safeParse({ ...validBase, appName });
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
if (!result.success) {
|
|
||||||
const appNameIssues = result.error.issues.filter(
|
|
||||||
(issue) => issue.path[0] === "appName",
|
|
||||||
);
|
|
||||||
expect(appNameIssues.map((issue) => issue.message)).toEqual([
|
|
||||||
APP_NAME_MESSAGE,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts an appName of letters, numbers, dots, underscores and hyphens", () => {
|
|
||||||
const result = apiCreateLibsql.safeParse({
|
|
||||||
...validBase,
|
|
||||||
appName: "my-libsql.db_1",
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses an appName longer than 63 characters", () => {
|
|
||||||
const result = apiCreateLibsql.safeParse({
|
|
||||||
...validBase,
|
|
||||||
appName: "a".repeat(64),
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
if (!result.success) {
|
|
||||||
const appNameIssues = result.error.issues.filter(
|
|
||||||
(issue) => issue.path[0] === "appName",
|
|
||||||
);
|
|
||||||
expect(appNameIssues.map((issue) => issue.code)).toEqual(["too_big"]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts an appName of 63 characters", () => {
|
|
||||||
const result = apiCreateLibsql.safeParse({
|
|
||||||
...validBase,
|
|
||||||
appName: "a".repeat(63),
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import {
|
|
||||||
getHostSystemStats,
|
|
||||||
isPhysicalDisk,
|
|
||||||
} from "@dokploy/server/monitoring/utils";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const GiB = 1024 ** 3;
|
|
||||||
const diskStat = (device: string, readGiB: number, writeGiB: number) => ({
|
|
||||||
device,
|
|
||||||
readBytes: { toBytes: () => readGiB * GiB },
|
|
||||||
writeBytes: { toBytes: () => writeGiB * GiB },
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("node-os-utils", () => ({
|
|
||||||
OSUtils: class {
|
|
||||||
cpu = { usage: async () => ({ success: false }) };
|
|
||||||
memory = { info: async () => ({ success: false }) };
|
|
||||||
network = { overview: async () => ({ success: false }) };
|
|
||||||
disk = {
|
|
||||||
stats: async () => ({
|
|
||||||
success: true,
|
|
||||||
data: [
|
|
||||||
diskStat("nvme0n1", 50, 100),
|
|
||||||
diskStat("nvme0n1p1", 50, 100),
|
|
||||||
diskStat("nvme1n1", 50, 100),
|
|
||||||
diskStat("nvme1n1p1", 50, 100),
|
|
||||||
diskStat("md0", 50, 100),
|
|
||||||
diskStat("md0p1", 50, 100),
|
|
||||||
diskStat("dm-0", 50, 100),
|
|
||||||
diskStat("zram0", 10, 20),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("isPhysicalDisk (#5513, #5385)", () => {
|
|
||||||
it.each([
|
|
||||||
"sda",
|
|
||||||
"sdab",
|
|
||||||
"hda",
|
|
||||||
"vda",
|
|
||||||
"xvda",
|
|
||||||
"nvme0n1",
|
|
||||||
"nvme1n1",
|
|
||||||
"mmcblk0",
|
|
||||||
])("counts whole disk %s", (device) => {
|
|
||||||
expect(isPhysicalDisk(device)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
"sda1",
|
|
||||||
"sdab12",
|
|
||||||
"hda1",
|
|
||||||
"vda1",
|
|
||||||
"xvda1",
|
|
||||||
"nvme0n1p1",
|
|
||||||
"nvme1n1p4",
|
|
||||||
"mmcblk0p1",
|
|
||||||
])("skips partition %s", (device) => {
|
|
||||||
expect(isPhysicalDisk(device)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(["md0", "md127", "md0p1", "md_d0p1", "dm-0", "dm-12"])(
|
|
||||||
"skips stacked device %s",
|
|
||||||
(device) => {
|
|
||||||
expect(isPhysicalDisk(device)).toBe(false);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each(["loop0", "ram0", "zram0", "sr0", "fd0"])(
|
|
||||||
"skips virtual device %s",
|
|
||||||
(device) => {
|
|
||||||
expect(isPhysicalDisk(device)).toBe(false);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("counts each write once on an mdadm RAID1 host", () => {
|
|
||||||
const devices = [
|
|
||||||
"nvme0n1",
|
|
||||||
"nvme0n1p1",
|
|
||||||
"nvme0n1p2",
|
|
||||||
"nvme0n1p3",
|
|
||||||
"nvme0n1p4",
|
|
||||||
"nvme1n1",
|
|
||||||
"nvme1n1p1",
|
|
||||||
"nvme1n1p2",
|
|
||||||
"nvme1n1p3",
|
|
||||||
"nvme1n1p4",
|
|
||||||
"md0",
|
|
||||||
"md1",
|
|
||||||
"md2",
|
|
||||||
"md3",
|
|
||||||
];
|
|
||||||
expect(devices.filter(isPhysicalDisk)).toEqual(["nvme0n1", "nvme1n1"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getHostSystemStats Block I/O (#5513, #5385)", () => {
|
|
||||||
it("sums only whole physical disks", async () => {
|
|
||||||
const stats = await getHostSystemStats();
|
|
||||||
expect(stats.BlockIO).toBe("100.00GiB / 200.00GiB");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -154,16 +154,7 @@ const hasRealMonitoring = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasDocker = () => {
|
describe.skipIf(hasRealMonitoring())(
|
||||||
try {
|
|
||||||
execSync("docker info", { stdio: "ignore" });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
describe.skipIf(!hasDocker() || hasRealMonitoring() || !process.env.CI)(
|
|
||||||
"setupMonitoring - legacy container cleanup (real docker)",
|
"setupMonitoring - legacy container cleanup (real docker)",
|
||||||
() => {
|
() => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const baseSettings: WebServerSettings = {
|
|||||||
docsUrl: null,
|
docsUrl: null,
|
||||||
errorPageTitle: null,
|
errorPageTitle: null,
|
||||||
errorPageDescription: null,
|
errorPageDescription: null,
|
||||||
ogImageUrl: null,
|
metaTitle: null,
|
||||||
footerText: null,
|
footerText: null,
|
||||||
},
|
},
|
||||||
cleanupCacheApplications: false,
|
cleanupCacheApplications: false,
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
import {
|
|
||||||
checkDeletionCode,
|
|
||||||
generateDeletionCode,
|
|
||||||
serializeDeletionCode,
|
|
||||||
} from "@dokploy/server/services/account-deletion";
|
|
||||||
import { deleteHubSpotContactByEmail } from "@dokploy/server/utils/tracking/hubspot";
|
|
||||||
import Stripe from "stripe";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { cancelStripeSubscriptions } from "@/server/utils/billing";
|
|
||||||
|
|
||||||
const makeStripe = (
|
|
||||||
subscriptions: Array<{ id: string; status: string }>,
|
|
||||||
listError?: Error,
|
|
||||||
) => {
|
|
||||||
const stripe = {
|
|
||||||
subscriptions: {
|
|
||||||
list: vi.fn(() =>
|
|
||||||
listError
|
|
||||||
? Promise.reject(listError)
|
|
||||||
: Promise.resolve({ data: subscriptions }),
|
|
||||||
),
|
|
||||||
cancel: vi.fn(() => Promise.resolve({})),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return stripe as unknown as Stripe & typeof stripe;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("cancelStripeSubscriptions", () => {
|
|
||||||
test("cancels every non-terminal subscription without invoicing", async () => {
|
|
||||||
const stripe = makeStripe([
|
|
||||||
{ id: "sub_active", status: "active" },
|
|
||||||
{ id: "sub_trial", status: "trialing" },
|
|
||||||
{ id: "sub_past_due", status: "past_due" },
|
|
||||||
{ id: "sub_canceled", status: "canceled" },
|
|
||||||
{ id: "sub_expired", status: "incomplete_expired" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await cancelStripeSubscriptions("cus_1", stripe);
|
|
||||||
|
|
||||||
expect(stripe.subscriptions.list).toHaveBeenCalledWith({
|
|
||||||
customer: "cus_1",
|
|
||||||
status: "all",
|
|
||||||
limit: 100,
|
|
||||||
});
|
|
||||||
expect(stripe.subscriptions.cancel).toHaveBeenCalledTimes(3);
|
|
||||||
expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_active", {
|
|
||||||
invoice_now: false,
|
|
||||||
prorate: false,
|
|
||||||
});
|
|
||||||
expect(result).toEqual({
|
|
||||||
cancelledSubscriptions: ["sub_active", "sub_trial", "sub_past_due"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("treats a missing customer as having nothing to cancel", async () => {
|
|
||||||
const missing = new Stripe.errors.StripeInvalidRequestError({
|
|
||||||
type: "invalid_request_error",
|
|
||||||
code: "resource_missing",
|
|
||||||
message: "No such customer",
|
|
||||||
});
|
|
||||||
const stripe = makeStripe([], missing);
|
|
||||||
|
|
||||||
const result = await cancelStripeSubscriptions("cus_missing", stripe);
|
|
||||||
|
|
||||||
expect(stripe.subscriptions.cancel).not.toHaveBeenCalled();
|
|
||||||
expect(result).toEqual({ cancelledSubscriptions: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("propagates unexpected Stripe errors", async () => {
|
|
||||||
const failure = new Stripe.errors.StripeAPIError({
|
|
||||||
type: "api_error",
|
|
||||||
message: "boom",
|
|
||||||
});
|
|
||||||
const stripe = makeStripe([], failure);
|
|
||||||
|
|
||||||
await expect(cancelStripeSubscriptions("cus_1", stripe)).rejects.toBe(
|
|
||||||
failure,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("deleteHubSpotContactByEmail", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("is skipped when no access token is configured", async () => {
|
|
||||||
const fetchMock = vi.fn();
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
|
|
||||||
expect(await deleteHubSpotContactByEmail("a@b.com", undefined)).toBe(
|
|
||||||
"skipped",
|
|
||||||
);
|
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("calls the GDPR delete endpoint with the email as id", async () => {
|
|
||||||
const fetchMock = vi.fn(() =>
|
|
||||||
Promise.resolve(new Response(null, { status: 204 })),
|
|
||||||
);
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
|
|
||||||
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
|
|
||||||
"deleted",
|
|
||||||
);
|
|
||||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [
|
|
||||||
string,
|
|
||||||
RequestInit,
|
|
||||||
];
|
|
||||||
expect(url).toBe(
|
|
||||||
"https://api.hubapi.com/crm/v3/objects/contacts/gdpr-delete",
|
|
||||||
);
|
|
||||||
expect(init.method).toBe("POST");
|
|
||||||
expect((init.headers as Record<string, string>).Authorization).toBe(
|
|
||||||
"Bearer token",
|
|
||||||
);
|
|
||||||
expect(JSON.parse(init.body as string)).toEqual({
|
|
||||||
idProperty: "email",
|
|
||||||
objectId: "a@b.com",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maps 404 to not_found and other errors to failed", async () => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn(() => Promise.resolve(new Response(null, { status: 404 }))),
|
|
||||||
);
|
|
||||||
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
|
|
||||||
"not_found",
|
|
||||||
);
|
|
||||||
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn(() => Promise.resolve(new Response("nope", { status: 500 }))),
|
|
||||||
);
|
|
||||||
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
|
|
||||||
"failed",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("account deletion codes", () => {
|
|
||||||
test("generates six-digit numeric codes", () => {
|
|
||||||
for (let i = 0; i < 50; i++) {
|
|
||||||
expect(generateDeletionCode()).toMatch(/^\d{6}$/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts the right code, ignoring surrounding whitespace", () => {
|
|
||||||
const value = serializeDeletionCode("482913");
|
|
||||||
|
|
||||||
expect(checkDeletionCode(value, "482913")).toEqual({
|
|
||||||
valid: true,
|
|
||||||
attempts: 0,
|
|
||||||
});
|
|
||||||
expect(checkDeletionCode(value, " 482913 ").valid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("counts failed attempts without revealing the hash", () => {
|
|
||||||
const value = serializeDeletionCode("482913");
|
|
||||||
|
|
||||||
const first = checkDeletionCode(value, "000000");
|
|
||||||
expect(first.valid).toBe(false);
|
|
||||||
if (first.valid) throw new Error("unreachable");
|
|
||||||
expect(first.attempts).toBe(1);
|
|
||||||
expect(JSON.parse(first.nextValue)).toEqual({
|
|
||||||
...JSON.parse(value),
|
|
||||||
attempts: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const second = checkDeletionCode(first.nextValue, "999999");
|
|
||||||
expect(second.valid).toBe(false);
|
|
||||||
if (second.valid) throw new Error("unreachable");
|
|
||||||
expect(second.attempts).toBe(2);
|
|
||||||
|
|
||||||
expect(checkDeletionCode(second.nextValue, "482913")).toEqual({
|
|
||||||
valid: true,
|
|
||||||
attempts: 2,
|
|
||||||
});
|
|
||||||
expect(value).not.toContain("482913");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { promises as fs } from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/services/server", () => ({
|
|
||||||
findServerById: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { pipeBetweenServers } from "@dokploy/server/utils/process/remoteStream";
|
|
||||||
|
|
||||||
describe("pipeBetweenServers", () => {
|
|
||||||
it("delivers a short source stream that ends before the target is ready", async () => {
|
|
||||||
const bytes = await pipeBetweenServers({
|
|
||||||
source: { serverId: null, command: "printf 'hello world'" },
|
|
||||||
target: { serverId: null, command: "sleep 0.3; cat > /dev/null" },
|
|
||||||
});
|
|
||||||
expect(bytes).toBe(11);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pipes data larger than the pipe buffer unchanged", async () => {
|
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-"));
|
|
||||||
const source = path.join(dir, "src.bin");
|
|
||||||
const target = path.join(dir, "dst.bin");
|
|
||||||
const size = 1024 * 1024;
|
|
||||||
const progress: number[] = [];
|
|
||||||
|
|
||||||
const bytes = await pipeBetweenServers({
|
|
||||||
source: {
|
|
||||||
serverId: null,
|
|
||||||
command: `head -c ${size} /dev/urandom | tee '${source}'`,
|
|
||||||
},
|
|
||||||
target: { serverId: null, command: `cat > '${target}'` },
|
|
||||||
onProgress: (transferred) => progress.push(transferred),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(bytes).toBe(size);
|
|
||||||
expect(progress.at(-1)).toBe(size);
|
|
||||||
expect(await fs.readFile(target)).toEqual(await fs.readFile(source));
|
|
||||||
await fs.rm(dir, { recursive: true, force: true });
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
it("reports a target failure with its stderr", async () => {
|
|
||||||
await expect(
|
|
||||||
pipeBetweenServers({
|
|
||||||
source: { serverId: null, command: "printf x" },
|
|
||||||
target: { serverId: null, command: "echo boom >&2; exit 3" },
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("target exited with code 3: boom");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a source failure", async () => {
|
|
||||||
await expect(
|
|
||||||
pipeBetweenServers({
|
|
||||||
source: { serverId: null, command: "exit 2" },
|
|
||||||
target: { serverId: null, command: "cat > /dev/null" },
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("source exited with code 2");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import { EventEmitter } from "node:events";
|
|
||||||
import http from "node:http";
|
|
||||||
import type { AddressInfo } from "node:net";
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import WebSocket from "ws";
|
|
||||||
|
|
||||||
class FakeChild extends EventEmitter {
|
|
||||||
stdout = new EventEmitter();
|
|
||||||
stderr = new EventEmitter();
|
|
||||||
killed = false;
|
|
||||||
exitCode: number | null = null;
|
|
||||||
signalCode: NodeJS.Signals | null = null;
|
|
||||||
signals: string[] = [];
|
|
||||||
// Mirrors ChildProcess: `killed` flips as soon as a signal is delivered,
|
|
||||||
// even if the process ignores it and keeps running.
|
|
||||||
ignoreSigterm = false;
|
|
||||||
|
|
||||||
kill(signal: NodeJS.Signals = "SIGTERM") {
|
|
||||||
this.signals.push(signal);
|
|
||||||
this.killed = true;
|
|
||||||
if (signal === "SIGTERM" && this.ignoreSigterm) return true;
|
|
||||||
this.signalCode = signal;
|
|
||||||
this.emit("close", null, signal);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const children = vi.hoisted(() => [] as FakeChild[]);
|
|
||||||
const nextChild = vi.hoisted(() => ({ ignoreSigterm: false }));
|
|
||||||
vi.mock("node:child_process", async (importOriginal) => ({
|
|
||||||
...(await importOriginal<typeof import("node:child_process")>()),
|
|
||||||
spawn: vi.fn(() => {
|
|
||||||
const child = new FakeChild();
|
|
||||||
child.ignoreSigterm = nextChild.ignoreSigterm;
|
|
||||||
children.push(child);
|
|
||||||
return child;
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const sshClients = vi.hoisted(
|
|
||||||
() => [] as { connect: ReturnType<typeof vi.fn> }[],
|
|
||||||
);
|
|
||||||
vi.mock("ssh2", () => ({
|
|
||||||
Client: vi.fn(function (this: Record<string, unknown>) {
|
|
||||||
this.on = vi.fn(() => this);
|
|
||||||
this.connect = vi.fn(() => this);
|
|
||||||
this.end = vi.fn();
|
|
||||||
sshClients.push(this as never);
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockValidateRequest = vi.hoisted(() => vi.fn());
|
|
||||||
const mockFindServerById = vi.hoisted(() => vi.fn());
|
|
||||||
vi.mock("@dokploy/server", () => ({
|
|
||||||
IS_CLOUD: false,
|
|
||||||
validateRequest: mockValidateRequest,
|
|
||||||
findServerById: mockFindServerById,
|
|
||||||
}));
|
|
||||||
vi.mock("@dokploy/server/wss/utils", () => ({
|
|
||||||
readValidDirectory: () => true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { setupDeploymentLogsWebSocketServer } from "@/server/wss/listen-deployment";
|
|
||||||
|
|
||||||
const AUTH = {
|
|
||||||
user: { id: "user-1" },
|
|
||||||
session: { activeOrganizationId: "org-1" },
|
|
||||||
};
|
|
||||||
|
|
||||||
const deferred = <T>() => {
|
|
||||||
let resolve!: (value: T) => void;
|
|
||||||
const promise = new Promise<T>((r) => {
|
|
||||||
resolve = r;
|
|
||||||
});
|
|
||||||
return { promise, resolve };
|
|
||||||
};
|
|
||||||
|
|
||||||
const flush = () => new Promise((r) => setTimeout(r, 50));
|
|
||||||
|
|
||||||
let server: http.Server;
|
|
||||||
let url: string;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
children.length = 0;
|
|
||||||
sshClients.length = 0;
|
|
||||||
nextChild.ignoreSigterm = false;
|
|
||||||
vi.clearAllMocks();
|
|
||||||
server = http.createServer();
|
|
||||||
setupDeploymentLogsWebSocketServer(server);
|
|
||||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
|
||||||
const { port } = server.address() as AddressInfo;
|
|
||||||
url = `ws://127.0.0.1:${port}/listen-deployment?logPath=/etc/dokploy/logs/app/deploy.log`;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
await new Promise((r) => server.close(r));
|
|
||||||
});
|
|
||||||
|
|
||||||
const connect = async (query = "") => {
|
|
||||||
const ws = new WebSocket(`${url}${query}`);
|
|
||||||
await new Promise((r) => ws.once("open", r));
|
|
||||||
return ws;
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeClient = async (ws: WebSocket) => {
|
|
||||||
ws.close();
|
|
||||||
await new Promise((r) => ws.once("close", r));
|
|
||||||
await flush();
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("deployment logs websocket (#5518)", () => {
|
|
||||||
it("kills tail when the client disconnects", async () => {
|
|
||||||
mockValidateRequest.mockResolvedValue(AUTH);
|
|
||||||
const ws = await connect();
|
|
||||||
await flush();
|
|
||||||
expect(children).toHaveLength(1);
|
|
||||||
|
|
||||||
await closeClient(ws);
|
|
||||||
expect(children[0]!.signals).toContain("SIGTERM");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not spawn tail when the client disconnects during auth", async () => {
|
|
||||||
const auth = deferred<typeof AUTH>();
|
|
||||||
mockValidateRequest.mockReturnValue(auth.promise);
|
|
||||||
const ws = await connect();
|
|
||||||
|
|
||||||
await closeClient(ws);
|
|
||||||
auth.resolve(AUTH);
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const leaked = children.filter(
|
|
||||||
(c) => c.exitCode === null && c.signalCode === null,
|
|
||||||
);
|
|
||||||
expect(leaked).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not open an SSH session when the client disconnects while loading the server", async () => {
|
|
||||||
mockValidateRequest.mockResolvedValue(AUTH);
|
|
||||||
const serverLookup = deferred<unknown>();
|
|
||||||
mockFindServerById.mockReturnValue(serverLookup.promise);
|
|
||||||
const ws = await connect("&serverId=srv-1");
|
|
||||||
|
|
||||||
await closeClient(ws);
|
|
||||||
serverLookup.resolve({
|
|
||||||
organizationId: "org-1",
|
|
||||||
sshKeyId: "key-1",
|
|
||||||
sshKey: { privateKey: "key" },
|
|
||||||
ipAddress: "10.0.0.1",
|
|
||||||
port: 22,
|
|
||||||
username: "root",
|
|
||||||
});
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const connected = sshClients.filter((c) => c.connect.mock.calls.length > 0);
|
|
||||||
expect(connected).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("force-kills tail with SIGKILL when SIGTERM does not stop it", async () => {
|
|
||||||
nextChild.ignoreSigterm = true;
|
|
||||||
mockValidateRequest.mockResolvedValue(AUTH);
|
|
||||||
const ws = await connect();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
await closeClient(ws);
|
|
||||||
await new Promise((r) => setTimeout(r, 1100));
|
|
||||||
expect(children[0]!.signals).toEqual(["SIGTERM", "SIGKILL"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -8,3 +8,4 @@ export { RestartPolicyForm } from "./restart-policy-form";
|
|||||||
export { RollbackConfigForm } from "./rollback-config-form";
|
export { RollbackConfigForm } from "./rollback-config-form";
|
||||||
export { StopGracePeriodForm } from "./stop-grace-period-form";
|
export { StopGracePeriodForm } from "./stop-grace-period-form";
|
||||||
export { UpdateConfigForm } from "./update-config-form";
|
export { UpdateConfigForm } from "./update-config-form";
|
||||||
|
export { filterEmptyValues, hasValues } from "./utils";
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Filters out undefined, null, and empty string values from form data
|
||||||
|
* Only returns fields that have actual values
|
||||||
|
*/
|
||||||
|
export const filterEmptyValues = (
|
||||||
|
formData: Record<string, any>,
|
||||||
|
): Record<string, any> => {
|
||||||
|
return Object.entries(formData).reduce(
|
||||||
|
(acc, [key, value]) => {
|
||||||
|
// Keep arrays even if empty (they might be intentionally cleared)
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length > 0) {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For other values, filter out undefined, null, and empty strings
|
||||||
|
else if (value !== undefined && value !== null && value !== "") {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, any>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if filtered data has any values to save
|
||||||
|
*/
|
||||||
|
export const hasValues = (data: Record<string, any>): boolean => {
|
||||||
|
return Object.keys(data).length > 0;
|
||||||
|
};
|
||||||
@@ -31,45 +31,6 @@ import { api } from "@/utils/api";
|
|||||||
|
|
||||||
// Railpack versions from https://github.com/railwayapp/railpack/releases
|
// Railpack versions from https://github.com/railwayapp/railpack/releases
|
||||||
export const RAILPACK_VERSIONS = [
|
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.4",
|
||||||
"0.15.3",
|
"0.15.3",
|
||||||
"0.15.2",
|
"0.15.2",
|
||||||
@@ -84,22 +45,12 @@ export const RAILPACK_VERSIONS = [
|
|||||||
"0.9.1",
|
"0.9.1",
|
||||||
"0.9.0",
|
"0.9.0",
|
||||||
"0.8.0",
|
"0.8.0",
|
||||||
"0.7.2",
|
|
||||||
"0.7.1",
|
|
||||||
"0.7.0",
|
"0.7.0",
|
||||||
"0.6.1",
|
|
||||||
"0.6.0",
|
"0.6.0",
|
||||||
"0.5.1",
|
|
||||||
"0.5.0",
|
"0.5.0",
|
||||||
"0.4.0",
|
"0.4.0",
|
||||||
"0.3.0",
|
"0.3.0",
|
||||||
"0.2.3",
|
|
||||||
"0.2.2",
|
"0.2.2",
|
||||||
"0.2.1",
|
|
||||||
"0.2.0",
|
|
||||||
"0.1.2",
|
|
||||||
"0.1.1",
|
|
||||||
"0.1.0",
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export enum BuildType {
|
export enum BuildType {
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
domainId,
|
domainId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: isOpen && !!domainId,
|
enabled: !!domainId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
applicationId: id,
|
applicationId: id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: isOpen && !!id,
|
enabled: !!id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: api.compose.one.useQuery(
|
: api.compose.one.useQuery(
|
||||||
@@ -175,7 +175,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
composeId: id,
|
composeId: id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: isOpen && !!id,
|
enabled: !!id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -187,14 +187,9 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
api.domain.generateDomain.useMutation();
|
api.domain.generateDomain.useMutation();
|
||||||
|
|
||||||
const { data: canGenerateTraefikMeDomains } =
|
const { data: canGenerateTraefikMeDomains } =
|
||||||
api.domain.canGenerateTraefikMeDomains.useQuery(
|
api.domain.canGenerateTraefikMeDomains.useQuery({
|
||||||
{
|
serverId: application?.serverId || "",
|
||||||
serverId: application?.serverId || "",
|
});
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: isOpen,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: services,
|
data: services,
|
||||||
@@ -209,7 +204,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
{
|
{
|
||||||
retry: false,
|
retry: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
enabled: isOpen && type === "compose" && !!id,
|
enabled: type === "compose" && !!id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import DOMPurify from "dompurify";
|
||||||
import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react";
|
import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -13,7 +14,6 @@ import { Dropzone } from "@/components/ui/dropzone";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
|
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { sanitizeSvg } from "@/utils/sanitize-svg";
|
|
||||||
|
|
||||||
interface ShowIconSettingsProps {
|
interface ShowIconSettingsProps {
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
@@ -89,6 +89,15 @@ export const ShowIconSettings = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sanitizeSvg = (svgContent: string): string | null => {
|
||||||
|
const clean = DOMPurify.sanitize(svgContent, {
|
||||||
|
USE_PROFILES: { svg: true, svgFilters: true },
|
||||||
|
ADD_TAGS: ["use"],
|
||||||
|
});
|
||||||
|
if (!clean) return null;
|
||||||
|
return `data:image/svg+xml;base64,${btoa(clean)}`;
|
||||||
|
};
|
||||||
|
|
||||||
const handleFileUpload = async (files: FileList | null) => {
|
const handleFileUpload = async (files: FileList | null) => {
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
|
|||||||
@@ -84,9 +84,9 @@ export const ShowPatches = ({ id, type }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<div className="flex flex-col gap-1">
|
<div>
|
||||||
<CardTitle className="text-xl">Patches</CardTitle>
|
<CardTitle>Patches</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Apply code patches to your repository during build. Patches are
|
Apply code patches to your repository during build. Patches are
|
||||||
applied after cloning the repository and before building.
|
applied after cloning the repository and before building.
|
||||||
|
|||||||
@@ -77,19 +77,23 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background h-full min-h-[50vh]">
|
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
||||||
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
<CardHeader className="px-0">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex justify-between items-center gap-y-2 flex-wrap">
|
||||||
<CardTitle className="text-xl">Scheduled Tasks</CardTitle>
|
<div className="flex flex-col gap-2">
|
||||||
<CardDescription>
|
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||||
Schedule tasks to run automatically at specified intervals.
|
Scheduled Tasks
|
||||||
</CardDescription>
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Schedule tasks to run automatically at specified intervals.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{schedules && schedules.length > 0 && (
|
||||||
|
<HandleSchedules id={id} scheduleType={scheduleType} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{schedules && schedules.length > 0 && (
|
|
||||||
<HandleSchedules id={id} scheduleType={scheduleType} />
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="px-0">
|
||||||
{isLoadingSchedules ? (
|
{isLoadingSchedules ? (
|
||||||
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||||
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||||
|
|||||||
+27
-18
@@ -77,26 +77,35 @@ export const ShowVolumeBackups = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background h-full min-h-[50vh]">
|
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
||||||
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
<CardHeader className="px-0">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex justify-between items-center flex-wrap gap-2">
|
||||||
<CardTitle className="text-xl">Volume Backups</CardTitle>
|
<div className="flex flex-col gap-2">
|
||||||
<CardDescription>
|
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||||
Schedule volume backups to run automatically at specified intervals
|
Volume Backups
|
||||||
</CardDescription>
|
</CardTitle>
|
||||||
</div>
|
<CardDescription>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
Schedule volume backups to run automatically at specified
|
||||||
{volumeBackups && volumeBackups.length > 0 && (
|
intervals
|
||||||
<>
|
</CardDescription>
|
||||||
<HandleVolumeBackups id={id} volumeBackupType={type} />
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<RestoreVolumeBackups id={id} type={type} serverId={serverId} />
|
{volumeBackups && volumeBackups.length > 0 && (
|
||||||
</div>
|
<>
|
||||||
</>
|
<HandleVolumeBackups id={id} volumeBackupType={type} />
|
||||||
)}
|
<div className="flex items-center gap-2">
|
||||||
|
<RestoreVolumeBackups
|
||||||
|
id={id}
|
||||||
|
type={type}
|
||||||
|
serverId={serverId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="px-0">
|
||||||
{isLoadingVolumeBackups ? (
|
{isLoadingVolumeBackups ? (
|
||||||
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||||
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import { Rocket } from "lucide-react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
export const TrialBanner = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { data: billingStatus } = api.stripe.getBillingStatus.useQuery();
|
|
||||||
const { mutateAsync: createCustomerPortalSession, isPending } =
|
|
||||||
api.stripe.createCustomerPortalSession.useMutation();
|
|
||||||
|
|
||||||
const handleAddPaymentMethod = async () => {
|
|
||||||
const session = await createCustomerPortalSession().catch(() => null);
|
|
||||||
if (session?.url) {
|
|
||||||
window.open(session.url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push("/dashboard/settings/billing");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!billingStatus?.isOnTrial || billingStatus.hasPaymentMethod) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const daysRemaining = billingStatus.trialDaysRemaining ?? 0;
|
|
||||||
const daysLabel = `${daysRemaining} day${daysRemaining === 1 ? "" : "s"}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="sticky top-0 z-20 mx-4 mt-4 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 rounded-lg border border-primary/20 bg-primary/10 px-4 py-2 text-sm">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<Rocket className="h-4 w-4 text-primary shrink-0" />
|
|
||||||
{daysRemaining > 0
|
|
||||||
? `You have ${daysLabel} left in your free trial. Add a payment method to keep your servers.`
|
|
||||||
: "Your free trial ends today. Add a payment method to keep your servers."}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="default"
|
|
||||||
className="h-7"
|
|
||||||
isLoading={isPending}
|
|
||||||
onClick={handleAddPaymentMethod}
|
|
||||||
>
|
|
||||||
Add payment method
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -31,7 +30,6 @@ interface Props {
|
|||||||
|
|
||||||
const AddRedirectSchema = z.object({
|
const AddRedirectSchema = z.object({
|
||||||
command: z.string(),
|
command: z.string(),
|
||||||
pullImages: z.boolean(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
||||||
@@ -59,25 +57,22 @@ export const AddCommandCompose = ({ composeId }: Props) => {
|
|||||||
const form = useForm<AddCommand>({
|
const form = useForm<AddCommand>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
command: "",
|
command: "",
|
||||||
pullImages: false,
|
|
||||||
},
|
},
|
||||||
resolver: zodResolver(AddRedirectSchema),
|
resolver: zodResolver(AddRedirectSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data?.command) {
|
||||||
form.reset({
|
form.reset({
|
||||||
command: data.command || "",
|
command: data?.command || "",
|
||||||
pullImages: data.pullImages ?? false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
|
||||||
|
|
||||||
const onSubmit = async (formData: AddCommand) => {
|
const onSubmit = async (data: AddCommand) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
composeId,
|
composeId,
|
||||||
command: formData.command,
|
command: data?.command,
|
||||||
pullImages: formData.pullImages,
|
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Command Updated");
|
toast.success("Command Updated");
|
||||||
@@ -114,30 +109,6 @@ export const AddCommandCompose = ({ composeId }: Props) => {
|
|||||||
<strong>docker</strong>.
|
<strong>docker</strong>.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{data?.composeType === "docker-compose" && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="pullImages"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-xs">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>Pull latest images on deploy</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Adds <strong>--pull always</strong> to the default
|
|
||||||
command so every deploy fetches the newest image for
|
|
||||||
each tag. Has no effect when a custom command is set.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="command"
|
name="command"
|
||||||
|
|||||||
@@ -1,216 +0,0 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
|
||||||
import copy from "copy-to-clipboard";
|
|
||||||
import { AlertTriangle, Copy, HardDriveDownload } from "lucide-react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
const freshVolumesSchema = z.object({
|
|
||||||
confirmName: z.string().min(1, {
|
|
||||||
message: "Compose name is required",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
type FreshVolumesForm = z.infer<typeof freshVolumesSchema>;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
composeId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FreshVolumes = ({ composeId }: Props) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
|
||||||
const canDeploy = permissions?.deployment.create ?? false;
|
|
||||||
const { data, refetch } = api.compose.one.useQuery(
|
|
||||||
{ composeId },
|
|
||||||
{ enabled: !!composeId },
|
|
||||||
);
|
|
||||||
const { mutateAsync: deploy, isPending } = api.compose.deploy.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<FreshVolumesForm>({
|
|
||||||
defaultValues: { confirmName: "" },
|
|
||||||
resolver: zodResolver(freshVolumesSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const expectedName = `${data?.name}/${data?.appName}`;
|
|
||||||
const isRunning = data?.composeStatus === "running";
|
|
||||||
|
|
||||||
const onSubmit = async (formData: FreshVolumesForm) => {
|
|
||||||
if (formData.confirmName !== expectedName) {
|
|
||||||
form.setError("confirmName", {
|
|
||||||
message: `Compose name must match "${expectedName}"`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await deploy({ composeId, freshVolumes: true })
|
|
||||||
.then(() => {
|
|
||||||
toast.success("Compose deployed with fresh volumes");
|
|
||||||
setIsOpen(false);
|
|
||||||
form.reset();
|
|
||||||
refetch();
|
|
||||||
router.push(
|
|
||||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error deploying compose");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!canDeploy || data?.composeType !== "docker-compose") return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="bg-background border-destructive/50">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-xl flex items-center gap-2">
|
|
||||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
|
||||||
Danger Zone
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h3 className="text-base font-semibold">
|
|
||||||
Deploy with Fresh Volumes
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Permanently wipes every volume of this compose and redeploys it
|
|
||||||
from a clean state. All persistent data (databases, uploads,
|
|
||||||
caches) will be lost.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Dialog
|
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setIsOpen(open);
|
|
||||||
if (!open) form.reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
isLoading={isPending}
|
|
||||||
variant="outline"
|
|
||||||
className="w-full border-destructive/50 hover:bg-destructive/10 hover:text-destructive text-destructive"
|
|
||||||
>
|
|
||||||
<HardDriveDownload className="mr-2 h-4 w-4" />
|
|
||||||
Deploy with Fresh Volumes
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
|
||||||
Are you absolutely sure?
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription asChild>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p>This action will:</p>
|
|
||||||
<ul className="list-disc list-inside space-y-1">
|
|
||||||
<li>Stop the current compose</li>
|
|
||||||
<li>Delete all volumes and their data</li>
|
|
||||||
<li>Redeploy the compose with empty volumes</li>
|
|
||||||
</ul>
|
|
||||||
<p className="font-medium text-destructive mt-4">
|
|
||||||
This action cannot be undone.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
id="hook-form-fresh-volumes"
|
|
||||||
className="grid w-full gap-4"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="confirmName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="flex items-center gap-2">
|
|
||||||
<span>
|
|
||||||
To confirm, type{" "}
|
|
||||||
<Badge
|
|
||||||
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
if (data?.name && data?.appName) {
|
|
||||||
copy(expectedName);
|
|
||||||
toast.success(
|
|
||||||
"Copied to clipboard. Be careful!",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{expectedName}
|
|
||||||
<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
@@ -158,6 +158,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
composePath: data.composePath,
|
composePath: data.composePath,
|
||||||
composeId,
|
composeId,
|
||||||
sourceType: "bitbucket",
|
sourceType: "bitbucket",
|
||||||
|
composeStatus: "idle",
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
|
|||||||
+1
@@ -97,6 +97,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
|||||||
composeId,
|
composeId,
|
||||||
sourceType: "git",
|
sourceType: "git",
|
||||||
composePath: values.composePath,
|
composePath: values.composePath,
|
||||||
|
composeStatus: "idle",
|
||||||
watchPaths: values.watchPaths || [],
|
watchPaths: values.watchPaths || [],
|
||||||
enableSubmodules: values.enableSubmodules,
|
enableSubmodules: values.enableSubmodules,
|
||||||
})
|
})
|
||||||
|
|||||||
+1
@@ -157,6 +157,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
giteaId: data.giteaId,
|
giteaId: data.giteaId,
|
||||||
composeId,
|
composeId,
|
||||||
sourceType: "gitea",
|
sourceType: "gitea",
|
||||||
|
composeStatus: "idle",
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
} as any)
|
} as any)
|
||||||
|
|||||||
+1
@@ -155,6 +155,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
composePath: data.composePath,
|
composePath: data.composePath,
|
||||||
githubId: data.githubId,
|
githubId: data.githubId,
|
||||||
sourceType: "github",
|
sourceType: "github",
|
||||||
|
composeStatus: "idle",
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
triggerType: data.triggerType,
|
triggerType: data.triggerType,
|
||||||
|
|||||||
+1
@@ -169,6 +169,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
gitlabProjectId: data.repository.id,
|
gitlabProjectId: data.repository.id,
|
||||||
gitlabPathNamespace: data.repository.gitlabPathNamespace,
|
gitlabPathNamespace: data.repository.gitlabPathNamespace,
|
||||||
sourceType: "gitlab",
|
sourceType: "gitlab",
|
||||||
|
composeStatus: "idle",
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { ClipboardList, DatabaseBackup, Play, Trash2 } from "lucide-react";
|
import {
|
||||||
|
ClipboardList,
|
||||||
|
Database,
|
||||||
|
DatabaseBackup,
|
||||||
|
Play,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -95,9 +101,12 @@ export const ShowBackups = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
<CardHeader className="flex flex-row justify-between gap-4 flex-wrap">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-0.5">
|
||||||
<CardTitle className="text-xl">Backups</CardTitle>
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
|
<Database className="size-6 text-muted-foreground" />
|
||||||
|
Backups
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Add backups to your database to save the data to a different
|
Add backups to your database to save the data to a different
|
||||||
provider.
|
provider.
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type ColumnFiltersState,
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
getPaginationRowModel,
|
getPaginationRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
type PaginationState,
|
type PaginationState,
|
||||||
@@ -25,6 +27,7 @@ import Link from "next/link";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -94,21 +97,14 @@ function getServiceInfo(d: DeploymentRow) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ShowDeploymentsTableProps {
|
export function ShowDeploymentsTable() {
|
||||||
globalFilter: string;
|
|
||||||
statusFilter: string;
|
|
||||||
typeFilter: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ShowDeploymentsTable({
|
|
||||||
globalFilter,
|
|
||||||
statusFilter,
|
|
||||||
typeFilter,
|
|
||||||
}: ShowDeploymentsTableProps) {
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
{ id: "createdAt", desc: true },
|
{ id: "createdAt", desc: true },
|
||||||
]);
|
]);
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
const [globalFilter, setGlobalFilter] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
@@ -450,140 +446,177 @@ export function ShowDeploymentsTable({
|
|||||||
columns,
|
columns,
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
globalFilter,
|
||||||
pagination,
|
pagination,
|
||||||
},
|
},
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-0">
|
<div className="space-y-2">
|
||||||
{isLoading ? (
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
|
<Input
|
||||||
<Loader2 className="size-4 animate-spin" />
|
placeholder="Search by name, project, environment, server..."
|
||||||
<span>Loading deployments...</span>
|
value={globalFilter}
|
||||||
</div>
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
) : (
|
className="max-w-xs"
|
||||||
<>
|
/>
|
||||||
<div className="rounded-md border overflow-x-auto">
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<Table>
|
<SelectTrigger className="w-[140px]">
|
||||||
<TableHeader>
|
<SelectValue placeholder="Status" />
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
</SelectTrigger>
|
||||||
<TableRow key={headerGroup.id}>
|
<SelectContent>
|
||||||
{headerGroup.headers.map((header) => (
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
<TableHead key={header.id}>
|
<SelectItem value="running">Running</SelectItem>
|
||||||
{header.isPlaceholder
|
<SelectItem value="done">Done</SelectItem>
|
||||||
? null
|
<SelectItem value="error">Error</SelectItem>
|
||||||
: flexRender(
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||||
header.column.columnDef.header,
|
</SelectContent>
|
||||||
header.getContext(),
|
</Select>
|
||||||
)}
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
</TableHead>
|
<SelectTrigger className="w-[140px]">
|
||||||
))}
|
<SelectValue placeholder="Type" />
|
||||||
</TableRow>
|
</SelectTrigger>
|
||||||
))}
|
<SelectContent>
|
||||||
</TableHeader>
|
<SelectItem value="all">All types</SelectItem>
|
||||||
<TableBody>
|
<SelectItem value="application">Application</SelectItem>
|
||||||
{table.getRowModel().rows?.length ? (
|
<SelectItem value="compose">Compose</SelectItem>
|
||||||
table.getRowModel().rows.map((row) => (
|
</SelectContent>
|
||||||
<TableRow key={row.id}>
|
</Select>
|
||||||
{row.getVisibleCells().map((cell) => (
|
</div>
|
||||||
<TableCell key={cell.id}>
|
<div className="px-0">
|
||||||
{flexRender(
|
{isLoading ? (
|
||||||
cell.column.columnDef.cell,
|
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
|
||||||
cell.getContext(),
|
<Loader2 className="size-4 animate-spin" />
|
||||||
)}
|
<span>Loading deployments...</span>
|
||||||
</TableCell>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="rounded-md border overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={columns.length}
|
|
||||||
className=" text-center"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
|
|
||||||
<Rocket className="size-8" />
|
|
||||||
<p className="font-medium">No deployments found</p>
|
|
||||||
<p className="text-sm">
|
|
||||||
Deployments from applications and compose will appear
|
|
||||||
here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
|
||||||
Rows per page
|
|
||||||
</span>
|
|
||||||
<Select
|
|
||||||
value={String(pagination.pageSize)}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setPagination((p) => ({
|
|
||||||
...p,
|
|
||||||
pageSize: Number(value),
|
|
||||||
pageIndex: 0,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-[70px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent side="top">
|
|
||||||
{[10, 25, 50, 100].map((size) => (
|
|
||||||
<SelectItem key={size} value={String(size)}>
|
|
||||||
{size}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</TableHeader>
|
||||||
</Select>
|
<TableBody>
|
||||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
{table.getRowModel().rows?.length ? (
|
||||||
Showing{" "}
|
table.getRowModel().rows.map((row) => (
|
||||||
{filteredData.length === 0
|
<TableRow key={row.id}>
|
||||||
? 0
|
{row.getVisibleCells().map((cell) => (
|
||||||
: pagination.pageIndex * pagination.pageSize + 1}{" "}
|
<TableCell key={cell.id}>
|
||||||
to{" "}
|
{flexRender(
|
||||||
{Math.min(
|
cell.column.columnDef.cell,
|
||||||
(pagination.pageIndex + 1) * pagination.pageSize,
|
cell.getContext(),
|
||||||
filteredData.length,
|
)}
|
||||||
)}{" "}
|
</TableCell>
|
||||||
of {filteredData.length} entries
|
))}
|
||||||
</span>
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className=" text-center"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
|
||||||
|
<Rocket className="size-8" />
|
||||||
|
<p className="font-medium">No deployments found</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Deployments from applications and compose will
|
||||||
|
appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Button
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
variant="outline"
|
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
size="sm"
|
Rows per page
|
||||||
className="h-8"
|
</span>
|
||||||
onClick={() => table.previousPage()}
|
<Select
|
||||||
disabled={!table.getCanPreviousPage()}
|
value={String(pagination.pageSize)}
|
||||||
>
|
onValueChange={(value) => {
|
||||||
<ChevronLeft className="size-4" />
|
setPagination((p) => ({
|
||||||
Previous
|
...p,
|
||||||
</Button>
|
pageSize: Number(value),
|
||||||
<Button
|
pageIndex: 0,
|
||||||
variant="outline"
|
}));
|
||||||
size="sm"
|
}}
|
||||||
className="h-8"
|
>
|
||||||
onClick={() => table.nextPage()}
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
disabled={!table.getCanNextPage()}
|
<SelectValue />
|
||||||
>
|
</SelectTrigger>
|
||||||
Next
|
<SelectContent side="top">
|
||||||
<ChevronRight className="size-4" />
|
{[10, 25, 50, 100].map((size) => (
|
||||||
</Button>
|
<SelectItem key={size} value={String(size)}>
|
||||||
|
{size}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
Showing{" "}
|
||||||
|
{filteredData.length === 0
|
||||||
|
? 0
|
||||||
|
: pagination.pageIndex * pagination.pageSize + 1}{" "}
|
||||||
|
to{" "}
|
||||||
|
{Math.min(
|
||||||
|
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||||
|
filteredData.length,
|
||||||
|
)}{" "}
|
||||||
|
of {filteredData.length} entries
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
import copy from "copy-to-clipboard";
|
|
||||||
import { Copy, Loader2, Trash2 } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
const CONFIRMATION_WORD = "DELETE";
|
|
||||||
|
|
||||||
export const DeleteAccountByEmail = () => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [confirmation, setConfirmation] = useState("");
|
|
||||||
const [record, setRecord] = useState<string | null>(null);
|
|
||||||
const { mutateAsync, isPending } =
|
|
||||||
api.user.deleteAccountByEmail.useMutation();
|
|
||||||
|
|
||||||
const canConfirm =
|
|
||||||
email.trim().length > 0 && confirmation.trim() === CONFIRMATION_WORD;
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
setEmail("");
|
|
||||||
setConfirmation("");
|
|
||||||
setRecord(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
try {
|
|
||||||
const result = await mutateAsync({ email: email.trim() });
|
|
||||||
setRecord(JSON.stringify(result, null, 2));
|
|
||||||
toast.success(`Account ${result.email} deleted`);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error ? error.message : "Error deleting account",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => {
|
|
||||||
setOpen(value);
|
|
||||||
if (!value) reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="destructive" className="gap-2">
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
Delete account
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete an account by email</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Cancels every Stripe subscription and deletes the user with all the
|
|
||||||
organizations they own. Invoices stay in Stripe for accounting. Use
|
|
||||||
it to fulfil data deletion requests.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{record ? (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<AlertBlock type="success">
|
|
||||||
Deletion completed. Keep this record as evidence of the request
|
|
||||||
being fulfilled.
|
|
||||||
</AlertBlock>
|
|
||||||
<pre className="text-xs bg-muted rounded-md p-3 overflow-x-auto max-h-64">
|
|
||||||
{record}
|
|
||||||
</pre>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="gap-2 self-end"
|
|
||||||
onClick={() => {
|
|
||||||
copy(record);
|
|
||||||
toast.success("Deletion record copied to clipboard");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
Copy record
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="delete-account-email">Account email</Label>
|
|
||||||
<Input
|
|
||||||
id="delete-account-email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder="user@example.com"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="delete-account-word">
|
|
||||||
Type <span className="font-mono">{CONFIRMATION_WORD}</span> to
|
|
||||||
confirm
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="delete-account-word"
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder={CONFIRMATION_WORD}
|
|
||||||
value={confirmation}
|
|
||||||
onChange={(e) => setConfirmation(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<DialogFooter>
|
|
||||||
{record ? (
|
|
||||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
disabled={!canConfirm || isPending}
|
|
||||||
onClick={handleDelete}
|
|
||||||
>
|
|
||||||
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
||||||
Delete account
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -46,7 +46,6 @@ import { authClient } from "@/lib/auth-client";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||||
import { DeleteAccountByEmail } from "./delete-account-by-email";
|
|
||||||
|
|
||||||
type User = typeof authClient.$Infer.Session.user;
|
type User = typeof authClient.$Infer.Session.user;
|
||||||
|
|
||||||
@@ -79,12 +78,12 @@ export const ImpersonationBar = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filteredUsers = response.data?.users.filter(
|
const filteredUsers = response.data?.users.filter(
|
||||||
// @ts-expect-error
|
// @ts-ignore
|
||||||
(user) => user.allowImpersonation && data?.user?.email !== user.email,
|
(user) => user.allowImpersonation && data?.user?.email !== user.email,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.error) {
|
if (!response.error) {
|
||||||
// @ts-expect-error
|
// @ts-ignore
|
||||||
setUsers(filteredUsers || []);
|
setUsers(filteredUsers || []);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -283,7 +282,6 @@ export const ImpersonationBar = () => {
|
|||||||
<Shield className="h-4 w-4" />
|
<Shield className="h-4 w-4" />
|
||||||
Impersonate
|
Impersonate
|
||||||
</Button>
|
</Button>
|
||||||
<DeleteAccountByEmail />
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-4 w-full flex-wrap">
|
<div className="flex items-center gap-4 w-full flex-wrap">
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { Fraunces } from "next/font/google";
|
|
||||||
|
|
||||||
export const displayFont = Fraunces({
|
|
||||||
subsets: ["latin"],
|
|
||||||
weight: ["600"],
|
|
||||||
style: ["normal", "italic"],
|
|
||||||
display: "swap",
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
const ONBOARDING_ACTIVE_KEY = "dokploy_onboarding_active";
|
|
||||||
const ONBOARDING_STATE_KEY = "dokploy_onboarding_state";
|
|
||||||
|
|
||||||
interface OnboardingState {
|
|
||||||
stepId?: string;
|
|
||||||
projectId?: string;
|
|
||||||
environmentId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const isOnboardingActive = () => {
|
|
||||||
if (typeof window === "undefined") return false;
|
|
||||||
try {
|
|
||||||
return window.localStorage.getItem(ONBOARDING_ACTIVE_KEY) === "true";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const markOnboardingActive = () => {
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(ONBOARDING_ACTIVE_KEY, "true");
|
|
||||||
} catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clearOnboardingActive = () => {
|
|
||||||
try {
|
|
||||||
window.localStorage.removeItem(ONBOARDING_ACTIVE_KEY);
|
|
||||||
window.localStorage.removeItem(ONBOARDING_STATE_KEY);
|
|
||||||
} catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getOnboardingState = (): OnboardingState => {
|
|
||||||
if (typeof window === "undefined") return {};
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(ONBOARDING_STATE_KEY);
|
|
||||||
return raw ? JSON.parse(raw) : {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const setOnboardingState = (state: OnboardingState) => {
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(
|
|
||||||
ONBOARDING_STATE_KEY,
|
|
||||||
JSON.stringify({ ...getOnboardingState(), ...state }),
|
|
||||||
);
|
|
||||||
} catch {}
|
|
||||||
};
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
import { defineStepper } from "@stepperize/react";
|
|
||||||
import { CheckIcon } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
|
||||||
import { Logo } from "@/components/shared/logo";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { getOnboardingState, setOnboardingState } from "./onboarding-lock";
|
|
||||||
import { CompleteStep } from "./steps/complete-step";
|
|
||||||
import { DeployStep } from "./steps/deploy-step";
|
|
||||||
import { PlanStep } from "./steps/plan-step";
|
|
||||||
import { ProjectStep } from "./steps/project-step";
|
|
||||||
import { ServerStep } from "./steps/server-step";
|
|
||||||
import { WelcomeStep } from "./steps/welcome-step";
|
|
||||||
|
|
||||||
const { useStepper, steps, Scoped } = defineStepper(
|
|
||||||
{ id: "welcome", title: "Welcome" },
|
|
||||||
{ id: "plan", title: "Pick a plan" },
|
|
||||||
{ id: "project", title: "New project" },
|
|
||||||
{ id: "server", title: "Connect server" },
|
|
||||||
{ id: "deploy", title: "Ship something" },
|
|
||||||
{ id: "complete", title: "You're live" },
|
|
||||||
);
|
|
||||||
|
|
||||||
type StepId = (typeof steps)[number]["id"];
|
|
||||||
const isStepId = (id: string | undefined): id is StepId =>
|
|
||||||
!!id && steps.some((step) => step.id === id);
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onClose: () => void | Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OnboardingWizard = ({ onClose }: Props) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const persisted = getOnboardingState();
|
|
||||||
const stepper = useStepper(
|
|
||||||
isStepId(persisted.stepId) ? persisted.stepId : undefined,
|
|
||||||
);
|
|
||||||
const [projectId, setProjectId] = useState<string | undefined>(
|
|
||||||
persisted.projectId,
|
|
||||||
);
|
|
||||||
const [environmentId, setEnvironmentId] = useState<string | undefined>(
|
|
||||||
persisted.environmentId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: isCloud = true } = api.settings.isCloud.useQuery();
|
|
||||||
const visibleStepIds = isCloud
|
|
||||||
? steps.map((step) => step.id)
|
|
||||||
: steps
|
|
||||||
.filter((step) => step.id !== "plan" && step.id !== "server")
|
|
||||||
.map((step) => step.id);
|
|
||||||
const visibleIndex = visibleStepIds.indexOf(stepper.current.id);
|
|
||||||
const isLastVisible = visibleIndex === visibleStepIds.length - 1;
|
|
||||||
const goToNextVisible = () => {
|
|
||||||
const nextId = visibleStepIds[visibleIndex + 1];
|
|
||||||
if (nextId) stepper.goTo(nextId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [skipAllOpen, setSkipAllOpen] = useState(false);
|
|
||||||
const handleSkipAll = async () => {
|
|
||||||
await onClose();
|
|
||||||
router.push(
|
|
||||||
projectId && environmentId
|
|
||||||
? `/dashboard/project/${projectId}/environment/${environmentId}`
|
|
||||||
: "/dashboard/projects",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOnboardingState({ stepId: stepper.current.id });
|
|
||||||
}, [stepper.current.id]);
|
|
||||||
|
|
||||||
const { error: projectCheckError } = api.project.one.useQuery(
|
|
||||||
{ projectId: projectId ?? "" },
|
|
||||||
{ enabled: !!projectId, retry: false },
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!projectCheckError || !projectId) return;
|
|
||||||
setProjectId(undefined);
|
|
||||||
setEnvironmentId(undefined);
|
|
||||||
setOnboardingState({ projectId: undefined, environmentId: undefined });
|
|
||||||
stepper.goTo("project");
|
|
||||||
}, [projectCheckError, projectId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="fixed inset-0 z-50 flex flex-col md:flex-row bg-background text-foreground">
|
|
||||||
<aside className="relative shrink-0 md:w-[300px] lg:w-[340px] overflow-hidden bg-zinc-950 text-zinc-400">
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute inset-0 opacity-[0.07]"
|
|
||||||
style={{
|
|
||||||
backgroundImage:
|
|
||||||
"linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px)",
|
|
||||||
backgroundSize: "28px 28px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="pointer-events-none absolute -top-24 -left-24 size-72 rounded-full bg-white/10 blur-[100px]" />
|
|
||||||
|
|
||||||
<div className="relative flex md:h-full flex-col p-6 lg:p-8">
|
|
||||||
<div className="flex items-center justify-between md:block">
|
|
||||||
<div className="flex items-center gap-2.5 text-zinc-50 [&_path]:!fill-white [&_path]:!stroke-white">
|
|
||||||
<Logo className="size-7" />
|
|
||||||
<span className="text-lg font-semibold tracking-tight">
|
|
||||||
Dokploy
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!isLastVisible && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSkipAllOpen(true)}
|
|
||||||
className="font-mono text-[11px] uppercase tracking-wider text-zinc-400 hover:text-zinc-100 transition-colors md:hidden"
|
|
||||||
>
|
|
||||||
Skip all
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Scoped>
|
|
||||||
<ol className="hidden md:flex flex-col gap-0.5 mt-12">
|
|
||||||
{visibleStepIds.map((id, index) => {
|
|
||||||
const step = steps.find((s) => s.id === id)!;
|
|
||||||
const isDone = index < visibleIndex;
|
|
||||||
const isCurrent = stepper.current.id === step.id;
|
|
||||||
return (
|
|
||||||
<li key={step.id} className="flex gap-3.5">
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<span
|
|
||||||
className={`flex items-center justify-center font-mono text-[11px] tabular-nums size-5 rounded-full ${
|
|
||||||
isCurrent
|
|
||||||
? "bg-white text-zinc-950 font-semibold"
|
|
||||||
: isDone
|
|
||||||
? "text-zinc-100"
|
|
||||||
: "text-zinc-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isDone ? (
|
|
||||||
<CheckIcon className="size-3" />
|
|
||||||
) : (
|
|
||||||
String(index + 1).padStart(2, "0")
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{index < visibleStepIds.length - 1 && (
|
|
||||||
<span
|
|
||||||
className={`w-px flex-1 min-h-7 my-1.5 ${
|
|
||||||
isDone ? "bg-zinc-400" : "bg-zinc-700"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isDone ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => stepper.goTo(step.id)}
|
|
||||||
className="text-sm font-medium transition-colors pb-6 last:pb-0 text-zinc-100 hover:text-white text-left"
|
|
||||||
>
|
|
||||||
{step.title}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<p
|
|
||||||
className={`text-sm font-medium transition-colors pb-6 last:pb-0 ${
|
|
||||||
isCurrent ? "text-white" : "text-zinc-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{step.title}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
</Scoped>
|
|
||||||
|
|
||||||
<div className="hidden md:flex flex-col gap-4 mt-auto pt-8 border-t border-zinc-800">
|
|
||||||
{!isLastVisible && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSkipAllOpen(true)}
|
|
||||||
className="w-fit font-mono text-[11px] uppercase tracking-wider text-zinc-400 hover:text-zinc-100 transition-colors"
|
|
||||||
>
|
|
||||||
Skip all →
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-3 text-zinc-500">
|
|
||||||
<Link
|
|
||||||
href="https://github.com/dokploy/dokploy"
|
|
||||||
target="_blank"
|
|
||||||
className="hover:text-zinc-200 transition-colors"
|
|
||||||
>
|
|
||||||
<GithubIcon />
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="https://x.com/getdokploy"
|
|
||||||
target="_blank"
|
|
||||||
className="hover:text-zinc-200 transition-colors"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
stroke="currentColor"
|
|
||||||
fill="currentColor"
|
|
||||||
strokeWidth="0"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="size-4"
|
|
||||||
>
|
|
||||||
<path d="M10.4883 14.651L15.25 21H22.25L14.3917 10.5223L20.9308 3H18.2808L13.1643 8.88578L8.75 3H1.75L9.26086 13.0145L2.31915 21H4.96917L10.4883 14.651ZM16.25 19L5.75 5H7.75L18.25 19H16.25Z" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="https://discord.com/invite/2tBnJ3jDJc"
|
|
||||||
target="_blank"
|
|
||||||
className="hover:text-zinc-200 transition-colors"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 48 48"
|
|
||||||
className="size-4"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M39.248,10.177c-2.804-1.287-5.812-2.235-8.956-2.778c-0.057-0.01-0.114,0.016-0.144,0.068 c-0.387,0.688-0.815,1.585-1.115,2.291c-3.382-0.506-6.747-0.506-10.059,0c-0.3-0.721-0.744-1.603-1.133-2.291 c-0.03-0.051-0.087-0.077-0.144-0.068c-3.143,0.541-6.15,1.489-8.956,2.778c-0.024,0.01-0.045,0.028-0.059,0.051 c-5.704,8.522-7.267,16.835-6.5,25.044c0.003,0.04,0.026,0.079,0.057,0.103c3.763,2.764,7.409,4.442,10.987,5.554 c0.057,0.017,0.118-0.003,0.154-0.051c0.846-1.156,1.601-2.374,2.248-3.656c0.038-0.075,0.002-0.164-0.076-0.194 c-1.197-0.454-2.336-1.007-3.432-1.636c-0.087-0.051-0.094-0.175-0.014-0.234c0.231-0.173,0.461-0.353,0.682-0.534 c0.04-0.033,0.095-0.04,0.142-0.019c7.201,3.288,14.997,3.288,22.113,0c0.047-0.023,0.102-0.016,0.144,0.017 c0.22,0.182,0.451,0.363,0.683,0.536c0.08,0.059,0.075,0.183-0.012,0.234c-1.096,0.641-2.236,1.182-3.434,1.634 c-0.078,0.03-0.113,0.12-0.075,0.196c0.661,1.28,1.415,2.498,2.246,3.654c0.035,0.049,0.097,0.07,0.154,0.052 c3.595-1.112,7.241-2.79,11.004-5.554c0.033-0.024,0.054-0.061,0.057-0.101c0.917-9.491-1.537-17.735-6.505-25.044 C39.293,10.205,39.272,10.187,39.248,10.177z M16.703,30.273c-2.168,0-3.954-1.99-3.954-4.435s1.752-4.435,3.954-4.435 c2.22,0,3.989,2.008,3.954,4.435C20.658,28.282,18.906,30.273,16.703,30.273z M31.324,30.273c-2.168,0-3.954-1.99-3.954-4.435 s1.752-4.435,3.954-4.435c2.22,0,3.989,2.008,3.954,4.435C35.278,28.282,33.544,30.273,31.324,30.273z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
<div className="mx-auto w-full max-w-2xl px-6 py-14 lg:py-20">
|
|
||||||
{stepper.switch({
|
|
||||||
welcome: () => <WelcomeStep onNext={goToNextVisible} />,
|
|
||||||
plan: () => <PlanStep onNext={goToNextVisible} />,
|
|
||||||
project: () => (
|
|
||||||
<ProjectStep
|
|
||||||
onNext={(project) => {
|
|
||||||
setProjectId(project.projectId);
|
|
||||||
setEnvironmentId(project.environmentId);
|
|
||||||
setOnboardingState({
|
|
||||||
projectId: project.projectId,
|
|
||||||
environmentId: project.environmentId,
|
|
||||||
});
|
|
||||||
goToNextVisible();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
server: () => <ServerStep onNext={goToNextVisible} />,
|
|
||||||
deploy: () => (
|
|
||||||
<DeployStep
|
|
||||||
environmentId={environmentId}
|
|
||||||
onNext={goToNextVisible}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
complete: () => (
|
|
||||||
<CompleteStep
|
|
||||||
projectId={projectId}
|
|
||||||
environmentId={environmentId}
|
|
||||||
onFinish={onClose}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog open={skipAllOpen} onOpenChange={setSkipAllOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Skip onboarding?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
If this is your first time using Dokploy, we recommend going
|
|
||||||
through these steps – it only takes a couple of minutes and gives
|
|
||||||
you a feel for how projects, servers, and deployments fit
|
|
||||||
together.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setSkipAllOpen(false)}>
|
|
||||||
Continue setup
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSkipAll}>Skip anyway</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import {
|
|
||||||
BookIcon,
|
|
||||||
DatabaseIcon,
|
|
||||||
GitMergeIcon,
|
|
||||||
GlobeIcon,
|
|
||||||
UsersIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import ConfettiExplosion from "react-confetti-explosion";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { displayFont } from "../font";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
projectId?: string;
|
|
||||||
environmentId?: string;
|
|
||||||
onFinish: () => void | Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const features = [
|
|
||||||
{
|
|
||||||
icon: DatabaseIcon,
|
|
||||||
title: "Databases",
|
|
||||||
description: "Postgres, MySQL, MongoDB, Redis and more, one click away.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: GlobeIcon,
|
|
||||||
title: "Custom domains",
|
|
||||||
description: "Attach your own domains and get automatic HTTPS.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: GitMergeIcon,
|
|
||||||
title: "CI/CD",
|
|
||||||
description: "Auto-deploy on every push from GitHub, GitLab or Bitbucket.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: UsersIcon,
|
|
||||||
title: "Team collaboration",
|
|
||||||
description: "Invite teammates with fine-grained permissions.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const CompleteStep = ({ projectId, environmentId, onFinish }: Props) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const [showConfetti, setShowConfetti] = useState(false);
|
|
||||||
const [isFinishing, setIsFinishing] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setShowConfetti(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const projectHref =
|
|
||||||
projectId && environmentId
|
|
||||||
? `/dashboard/project/${projectId}/environment/${environmentId}`
|
|
||||||
: "/dashboard/projects";
|
|
||||||
|
|
||||||
const handleFinish = async () => {
|
|
||||||
setIsFinishing(true);
|
|
||||||
await onFinish();
|
|
||||||
router.push(projectHref);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-10">
|
|
||||||
<div className="fixed inset-x-0 top-0 flex justify-center pointer-events-none">
|
|
||||||
{showConfetti && (
|
|
||||||
<ConfettiExplosion
|
|
||||||
duration={3000}
|
|
||||||
force={0.35}
|
|
||||||
particleCount={200}
|
|
||||||
width={1200}
|
|
||||||
zIndex={60}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
|
||||||
Done
|
|
||||||
</span>
|
|
||||||
<h1
|
|
||||||
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
|
|
||||||
>
|
|
||||||
You're all set.
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
|
||||||
This is just the beginning — here's some of what else you can do.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl className="flex flex-col divide-y">
|
|
||||||
{features.map((feature, index) => (
|
|
||||||
<div key={feature.title} className="flex gap-6 py-5 first:pt-0">
|
|
||||||
<dt className="font-mono text-xs text-muted-foreground pt-1 shrink-0 w-6">
|
|
||||||
{String(index + 1).padStart(2, "0")}
|
|
||||||
</dt>
|
|
||||||
<dd className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium flex items-center gap-2">
|
|
||||||
<feature.icon className="size-4 text-muted-foreground" />
|
|
||||||
{feature.title}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{feature.description}
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
className="w-fit px-8"
|
|
||||||
isLoading={isFinishing}
|
|
||||||
onClick={handleFinish}
|
|
||||||
>
|
|
||||||
Go to my project
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" asChild>
|
|
||||||
<Link
|
|
||||||
href="https://docs.dokploy.com/docs/core"
|
|
||||||
target="_blank"
|
|
||||||
className="flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<BookIcon size={14} />
|
|
||||||
Read the docs
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
import copy from "copy-to-clipboard";
|
|
||||||
import {
|
|
||||||
ArrowUpRightIcon,
|
|
||||||
CheckCircle2,
|
|
||||||
CopyIcon,
|
|
||||||
Loader2,
|
|
||||||
XCircleIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { displayFont } from "../font";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
environmentId?: string;
|
|
||||||
onNext: () => void;
|
|
||||||
/** Drops the onboarding wizard's display serif for callers (e.g. the
|
|
||||||
* post-checkout welcome modal) that want the app's regular typography. */
|
|
||||||
plainTitle?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CURATED_TEMPLATES = [
|
|
||||||
{ id: "wordpress", name: "WordPress", logo: "wordpress.png" },
|
|
||||||
{ id: "ghost", name: "Ghost", logo: "ghost.jpeg" },
|
|
||||||
{ id: "n8n", name: "n8n", logo: "n8n.png" },
|
|
||||||
{ id: "uptime-kuma", name: "Uptime Kuma", logo: "uptime-kuma.png" },
|
|
||||||
];
|
|
||||||
|
|
||||||
type Deploying = { kind: "app" | "template"; id: string; url: string };
|
|
||||||
|
|
||||||
export const DeployStep = ({ environmentId, onNext, plainTitle }: Props) => {
|
|
||||||
const titleClassName = plainTitle
|
|
||||||
? "text-xl font-semibold tracking-tight"
|
|
||||||
: `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`;
|
|
||||||
const { data: isCloud = true } = api.settings.isCloud.useQuery();
|
|
||||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
|
||||||
const serverId = servers?.[0]?.serverId;
|
|
||||||
|
|
||||||
const [deploying, setDeploying] = useState<Deploying | null>(null);
|
|
||||||
const [starting, setStarting] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { mutateAsync: deployNginx } =
|
|
||||||
api.application.deployNginxQuickstart.useMutation();
|
|
||||||
const { mutateAsync: deployTemplate } =
|
|
||||||
api.compose.deployTemplate.useMutation();
|
|
||||||
const { mutateAsync: deployCompose } = api.compose.deploy.useMutation();
|
|
||||||
const utils = api.useUtils();
|
|
||||||
|
|
||||||
const { data: application } = api.application.one.useQuery(
|
|
||||||
{ applicationId: deploying?.id ?? "" },
|
|
||||||
{
|
|
||||||
enabled: deploying?.kind === "app",
|
|
||||||
refetchInterval: (query) => {
|
|
||||||
const status = query.state.data?.applicationStatus;
|
|
||||||
return status === "done" || status === "error" ? false : 2500;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const { data: compose } = api.compose.one.useQuery(
|
|
||||||
{ composeId: deploying?.id ?? "" },
|
|
||||||
{
|
|
||||||
enabled: deploying?.kind === "template",
|
|
||||||
refetchInterval: (query) => {
|
|
||||||
const status = query.state.data?.composeStatus;
|
|
||||||
return status === "done" || status === "error" ? false : 2500;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const status =
|
|
||||||
deploying?.kind === "app"
|
|
||||||
? application?.applicationStatus
|
|
||||||
: compose?.composeStatus;
|
|
||||||
|
|
||||||
if (!environmentId || (isCloud && !serverId)) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
|
||||||
First deploy
|
|
||||||
</span>
|
|
||||||
<h1 className={titleClassName}>Deploy something.</h1>
|
|
||||||
</div>
|
|
||||||
<AlertBlock type="info" className="max-w-md">
|
|
||||||
{isCloud
|
|
||||||
? "You'll need a project and a connected server before deploying — you can do that anytime from the dashboard."
|
|
||||||
: "You'll need a project before deploying — you can do that anytime from the dashboard."}
|
|
||||||
</AlertBlock>
|
|
||||||
<Button onClick={onNext} className="w-fit px-8">
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNginx = async () => {
|
|
||||||
setStarting("nginx");
|
|
||||||
try {
|
|
||||||
const res = await deployNginx({ environmentId, serverId });
|
|
||||||
setDeploying({ kind: "app", id: res.applicationId, url: res.domainUrl });
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error ? error.message : "Error deploying the app",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setStarting(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTemplate = async (id: string) => {
|
|
||||||
setStarting(id);
|
|
||||||
try {
|
|
||||||
const composeResult = await deployTemplate({
|
|
||||||
environmentId,
|
|
||||||
serverId,
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
await deployCompose({ composeId: composeResult.composeId });
|
|
||||||
const domains = await utils.domain.byComposeId.fetch({
|
|
||||||
composeId: composeResult.composeId,
|
|
||||||
});
|
|
||||||
const host = domains?.[0]?.host;
|
|
||||||
setDeploying({
|
|
||||||
kind: "template",
|
|
||||||
id: composeResult.composeId,
|
|
||||||
url: host ? `http://${host}` : "",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error ? error.message : "Error deploying the template",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setStarting(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (deploying) {
|
|
||||||
const isDone = status === "done";
|
|
||||||
const isError = status === "error";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<span
|
|
||||||
className={`font-mono text-xs uppercase tracking-[0.2em] flex items-center gap-2 ${
|
|
||||||
isError ? "text-destructive" : "text-primary"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isDone ? (
|
|
||||||
<CheckCircle2 className="size-3.5" />
|
|
||||||
) : isError ? (
|
|
||||||
<XCircleIcon className="size-3.5" />
|
|
||||||
) : (
|
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
|
||||||
)}
|
|
||||||
{isDone ? "Live" : isError ? "Deploy failed" : "Deploying"}
|
|
||||||
</span>
|
|
||||||
<h1 className={titleClassName}>
|
|
||||||
{isDone
|
|
||||||
? "It's live."
|
|
||||||
: isError
|
|
||||||
? "Something went wrong."
|
|
||||||
: "Building your app..."}
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
|
||||||
{isDone
|
|
||||||
? "Your app is up and reachable at:"
|
|
||||||
: isError
|
|
||||||
? "The deployment failed — you can check the logs from the dashboard later."
|
|
||||||
: "This usually takes a minute or two the first time."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isDone && deploying.url ? (
|
|
||||||
<div className="flex flex-col rounded-2xl border overflow-hidden max-w-md">
|
|
||||||
<div className="flex items-center gap-2 px-5 py-3 border-b bg-muted/30">
|
|
||||||
<span className="size-2 rounded-full bg-green-500 shrink-0" />
|
|
||||||
<span className="font-mono text-xs text-muted-foreground">
|
|
||||||
Reachable now
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3 p-5">
|
|
||||||
<span className="font-mono text-sm truncate">
|
|
||||||
{deploying.url}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
|
||||||
copy(deploying.url);
|
|
||||||
toast.success("Copied to clipboard");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CopyIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" asChild>
|
|
||||||
<a href={deploying.url} target="_blank" rel="noreferrer">
|
|
||||||
<ArrowUpRightIcon className="size-4" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : !isDone && !isError ? (
|
|
||||||
<div className="rounded-2xl border p-5 max-w-md">
|
|
||||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="size-4 animate-spin shrink-0" />
|
|
||||||
Waiting for the build to finish...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={onNext}
|
|
||||||
className="w-fit px-8"
|
|
||||||
disabled={!isDone && !isError}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-10">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
|
||||||
First deploy
|
|
||||||
</span>
|
|
||||||
<h1 className={titleClassName}>Ship something.</h1>
|
|
||||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
|
||||||
Pick a quickstart — we'll generate a domain and deploy it for you.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border rounded-2xl overflow-hidden border">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleNginx}
|
|
||||||
disabled={!!starting}
|
|
||||||
className="flex flex-col items-start justify-between gap-8 bg-background p-7 text-left hover:bg-muted/40 transition-colors disabled:opacity-60"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">Simple app</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
A "Hello World" demo app, live in seconds.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="font-mono text-xs uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
|
||||||
{starting === "nginx" ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
|
||||||
Starting...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Deploy now →"
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex flex-col justify-between gap-8 bg-background p-7">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">Docker Compose template</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Popular open source stacks, one click away.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{CURATED_TEMPLATES.map((template) => (
|
|
||||||
<Button
|
|
||||||
key={template.id}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={!!starting}
|
|
||||||
isLoading={starting === template.id}
|
|
||||||
onClick={() => handleTemplate(template.id)}
|
|
||||||
className="justify-start"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={`https://templates.dokploy.com/blueprints/${template.id}/${template.logo}`}
|
|
||||||
alt={template.name}
|
|
||||||
className="size-4 object-contain shrink-0"
|
|
||||||
/>
|
|
||||||
{template.name}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import { loadStripe } from "@stripe/stripe-js";
|
|
||||||
import { ArrowRightIcon, CheckIcon } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
|
||||||
calculatePriceHobby,
|
|
||||||
calculatePriceStartup,
|
|
||||||
STARTUP_SERVERS_INCLUDED,
|
|
||||||
} from "@/components/dashboard/settings/billing/show-billing";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { displayFont } from "../font";
|
|
||||||
|
|
||||||
const stripePromise = loadStripe(
|
|
||||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
|
|
||||||
);
|
|
||||||
|
|
||||||
type Tier = "hobby" | "startup";
|
|
||||||
|
|
||||||
const PLANS: {
|
|
||||||
tier: Tier;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
price: number;
|
|
||||||
features: string[];
|
|
||||||
recommended?: boolean;
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
tier: "hobby",
|
|
||||||
name: "Hobby",
|
|
||||||
description: "For individual developers",
|
|
||||||
price: calculatePriceHobby(1, false),
|
|
||||||
features: [
|
|
||||||
"Setup 1 server",
|
|
||||||
"Unlimited apps & databases",
|
|
||||||
"2 environments",
|
|
||||||
"Community support",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tier: "startup",
|
|
||||||
name: "Startup",
|
|
||||||
description: "For small to mid-size teams",
|
|
||||||
price: calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false),
|
|
||||||
recommended: true,
|
|
||||||
features: [
|
|
||||||
`Setup up to ${STARTUP_SERVERS_INCLUDED} servers`,
|
|
||||||
"Unlimited users & environments",
|
|
||||||
"Basic RBAC + 2FA",
|
|
||||||
"Email & chat support",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onNext: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PlanStep = ({ onNext }: Props) => {
|
|
||||||
const [loading, setLoading] = useState<
|
|
||||||
`${"trial" | "checkout"}:${Tier}` | null
|
|
||||||
>(null);
|
|
||||||
const { data } = api.stripe.getProducts.useQuery();
|
|
||||||
const { mutateAsync: createCheckoutSession } =
|
|
||||||
api.stripe.createCheckoutSession.useMutation();
|
|
||||||
const { mutateAsync: startFreeTrial } =
|
|
||||||
api.stripe.startFreeTrial.useMutation();
|
|
||||||
const utils = api.useUtils();
|
|
||||||
|
|
||||||
const handleCheckout = async (tier: Tier) => {
|
|
||||||
if (!data) return;
|
|
||||||
const productId =
|
|
||||||
tier === "hobby" ? data.hobbyProductId : data.startupProductId;
|
|
||||||
if (!productId) return;
|
|
||||||
setLoading(`checkout:${tier}`);
|
|
||||||
try {
|
|
||||||
const stripe = await stripePromise;
|
|
||||||
const session = await createCheckoutSession({
|
|
||||||
tier,
|
|
||||||
productId,
|
|
||||||
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
|
|
||||||
isAnnual: false,
|
|
||||||
});
|
|
||||||
await stripe?.redirectToCheckout({ sessionId: session.sessionId });
|
|
||||||
} catch {
|
|
||||||
toast.error("Error starting checkout");
|
|
||||||
setLoading(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTrial = async (tier: Tier) => {
|
|
||||||
setLoading(`trial:${tier}`);
|
|
||||||
try {
|
|
||||||
await startFreeTrial({ tier });
|
|
||||||
await utils.project.onboardingStatus.invalidate();
|
|
||||||
toast.success("Your 7-day trial has started");
|
|
||||||
onNext();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error ? error.message : "Error starting trial",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setLoading(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-10">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
|
||||||
Billing
|
|
||||||
</span>
|
|
||||||
<h1
|
|
||||||
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
|
|
||||||
>
|
|
||||||
Start free, upgrade when ready.
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
|
||||||
Try any plan free for 7 days. No credit card required — add one only
|
|
||||||
if you decide to stay.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border rounded-2xl overflow-hidden border">
|
|
||||||
{PLANS.map((plan) => {
|
|
||||||
const productId =
|
|
||||||
plan.tier === "hobby"
|
|
||||||
? data?.hobbyProductId
|
|
||||||
: data?.startupProductId;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={plan.tier}
|
|
||||||
className="flex flex-col justify-between gap-6 bg-background p-7"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
{plan.recommended && (
|
|
||||||
<span className="inline-flex items-center rounded-full bg-primary text-primary-foreground font-mono text-[10px] font-semibold uppercase tracking-[0.15em] px-2.5 py-1 mb-3">
|
|
||||||
Recommended
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<p className="font-medium">{plan.name}</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
{plan.description}
|
|
||||||
</p>
|
|
||||||
<p className="text-3xl font-semibold mt-4 tabular-nums">
|
|
||||||
${plan.price.toFixed(2)}
|
|
||||||
<span className="text-sm font-normal text-muted-foreground">
|
|
||||||
{" "}
|
|
||||||
/mo
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
<ul className="flex flex-col gap-1.5 mt-4">
|
|
||||||
{plan.features.map((f) => (
|
|
||||||
<li
|
|
||||||
key={f}
|
|
||||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
<CheckIcon className="size-3.5 shrink-0" />
|
|
||||||
{f}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Button
|
|
||||||
isLoading={loading === `trial:${plan.tier}`}
|
|
||||||
disabled={loading !== null}
|
|
||||||
onClick={() => handleTrial(plan.tier)}
|
|
||||||
>
|
|
||||||
Start 7-day free trial
|
|
||||||
<ArrowRightIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
isLoading={loading === `checkout:${plan.tier}`}
|
|
||||||
disabled={loading !== null || !productId}
|
|
||||||
onClick={() => handleCheckout(plan.tier)}
|
|
||||||
>
|
|
||||||
Subscribe now
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { displayFont } from "../font";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
name: z.string().min(1, "Name is required"),
|
|
||||||
description: z.string().optional(),
|
|
||||||
});
|
|
||||||
type Schema = z.infer<typeof schema>;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onNext: (project: { projectId: string; environmentId: string }) => void;
|
|
||||||
/** Drops the onboarding wizard's display serif for callers (e.g. the
|
|
||||||
* post-checkout welcome modal) that want the app's regular typography. */
|
|
||||||
plainTitle?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProjectStep = ({ onNext, plainTitle }: Props) => {
|
|
||||||
const titleClassName = plainTitle
|
|
||||||
? "text-xl font-semibold tracking-tight"
|
|
||||||
: `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`;
|
|
||||||
const { mutateAsync, isPending } = api.project.create.useMutation();
|
|
||||||
const utils = api.useUtils();
|
|
||||||
|
|
||||||
const form = useForm<Schema>({
|
|
||||||
defaultValues: { name: "My First Project", description: "" },
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = async (data: Schema) => {
|
|
||||||
try {
|
|
||||||
const result = await mutateAsync(data);
|
|
||||||
await utils.project.all.invalidate();
|
|
||||||
toast.success("Project created");
|
|
||||||
onNext({
|
|
||||||
projectId: result.project.projectId,
|
|
||||||
environmentId: result.environment?.environmentId ?? "",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error ? error.message : "Error creating the project",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-10">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
|
||||||
Workspace
|
|
||||||
</span>
|
|
||||||
<h1 className={titleClassName}>Create your first project.</h1>
|
|
||||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
|
||||||
Projects group your apps, databases, and environments together.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="flex flex-col gap-5 max-w-sm"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="My First Project" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="description"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Description (optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
placeholder="What is this project for?"
|
|
||||||
className="resize-none"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
isLoading={isPending}
|
|
||||||
className="w-fit px-8 mt-2"
|
|
||||||
>
|
|
||||||
Create project
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
|
||||||
import copy from "copy-to-clipboard";
|
|
||||||
import {
|
|
||||||
CheckCircle2,
|
|
||||||
CopyIcon,
|
|
||||||
InfoIcon,
|
|
||||||
Loader2,
|
|
||||||
XCircle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { displayFont } from "../font";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
name: z.string().min(1, "Name is required"),
|
|
||||||
ipAddress: z
|
|
||||||
.string()
|
|
||||||
.min(1, "IP address is required")
|
|
||||||
.refine((value) => !/\s/.test(value), "IP address cannot contain spaces"),
|
|
||||||
});
|
|
||||||
type Schema = z.infer<typeof schema>;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onNext: () => void;
|
|
||||||
/** Drops the onboarding wizard's display serif for callers (e.g. the
|
|
||||||
* post-checkout welcome modal) that want the app's regular typography. */
|
|
||||||
plainTitle?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StatusRow = ({
|
|
||||||
label,
|
|
||||||
ok,
|
|
||||||
pending,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
ok?: boolean;
|
|
||||||
pending?: boolean;
|
|
||||||
}) => (
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
{ok ? (
|
|
||||||
<CheckCircle2 className="size-4 text-green-600 dark:text-green-500 shrink-0" />
|
|
||||||
) : pending ? (
|
|
||||||
<Loader2 className="size-4 text-muted-foreground shrink-0 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="size-4 text-muted-foreground shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className={ok ? "" : "text-muted-foreground"}>{label}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ServerStep = ({ onNext, plainTitle }: Props) => {
|
|
||||||
const titleClassName = plainTitle
|
|
||||||
? "text-xl font-semibold tracking-tight"
|
|
||||||
: `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`;
|
|
||||||
|
|
||||||
const { data: sshKeys, refetch: refetchSSHKeys } = api.sshKey.all.useQuery();
|
|
||||||
const generateSSHKey = api.sshKey.generate.useMutation();
|
|
||||||
const createSSHKey = api.sshKey.create.useMutation();
|
|
||||||
const hasCreatedKey = useRef(false);
|
|
||||||
|
|
||||||
const cloudSSHKey = sshKeys?.find(
|
|
||||||
(sshKey) => sshKey.name === "dokploy-onboarding-ssh-key",
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const ensureKey = async () => {
|
|
||||||
if (!sshKeys || cloudSSHKey || hasCreatedKey.current) return;
|
|
||||||
hasCreatedKey.current = true;
|
|
||||||
try {
|
|
||||||
const keys = await generateSSHKey.mutateAsync({ type: "rsa" });
|
|
||||||
await createSSHKey.mutateAsync({
|
|
||||||
name: "dokploy-onboarding-ssh-key",
|
|
||||||
description: "Used during onboarding",
|
|
||||||
privateKey: keys.privateKey,
|
|
||||||
publicKey: keys.publicKey,
|
|
||||||
organizationId: "",
|
|
||||||
});
|
|
||||||
await refetchSSHKeys();
|
|
||||||
} catch {
|
|
||||||
hasCreatedKey.current = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ensureKey();
|
|
||||||
}, [sshKeys]);
|
|
||||||
|
|
||||||
const { data: existingServers } = api.server.all.useQuery();
|
|
||||||
const [createdServerId, setCreatedServerId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!createdServerId && existingServers && existingServers.length > 0) {
|
|
||||||
setCreatedServerId(existingServers[0]!.serverId);
|
|
||||||
}
|
|
||||||
}, [existingServers, createdServerId]);
|
|
||||||
|
|
||||||
const { data: canCreateMoreServers } =
|
|
||||||
api.stripe.canCreateMoreServers.useQuery();
|
|
||||||
const { mutateAsync: createServer, isPending: isCreating } =
|
|
||||||
api.server.create.useMutation();
|
|
||||||
const checkIsReady = (validation?: {
|
|
||||||
docker?: { enabled?: boolean };
|
|
||||||
isDokployNetworkInstalled?: boolean;
|
|
||||||
}) =>
|
|
||||||
!!validation?.docker?.enabled && !!validation?.isDokployNetworkInstalled;
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: validation,
|
|
||||||
refetch: refetchValidation,
|
|
||||||
isFetching: isValidating,
|
|
||||||
} = api.server.validate.useQuery(
|
|
||||||
{ serverId: createdServerId ?? "" },
|
|
||||||
{
|
|
||||||
enabled: !!createdServerId,
|
|
||||||
refetchInterval: (query) =>
|
|
||||||
checkIsReady(query.state.data) ? false : 5000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const isReady = checkIsReady(validation);
|
|
||||||
|
|
||||||
const [isSettingUp, setIsSettingUp] = useState(false);
|
|
||||||
const hasStartedSetup = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (createdServerId && !hasStartedSetup.current && !isReady) {
|
|
||||||
hasStartedSetup.current = true;
|
|
||||||
setIsSettingUp(true);
|
|
||||||
}
|
|
||||||
}, [createdServerId, isReady]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isSettingUp) return;
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
setIsSettingUp(false);
|
|
||||||
toast.error(
|
|
||||||
"Setup is taking too long — check the server is reachable, then try again.",
|
|
||||||
);
|
|
||||||
}, 120_000);
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, [isSettingUp]);
|
|
||||||
|
|
||||||
api.server.setupWithLogs.useSubscription(
|
|
||||||
{ serverId: createdServerId ?? "" },
|
|
||||||
{
|
|
||||||
enabled: !!createdServerId && isSettingUp,
|
|
||||||
onData(log) {
|
|
||||||
if (typeof log === "string" && log.includes("Setup Server:")) {
|
|
||||||
setIsSettingUp(false);
|
|
||||||
refetchValidation();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError(error) {
|
|
||||||
setIsSettingUp(false);
|
|
||||||
toast.error("Error setting up the server");
|
|
||||||
console.error(error);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const form = useForm<Schema>({
|
|
||||||
defaultValues: { name: "My First Server", ipAddress: "" },
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = async (data: Schema) => {
|
|
||||||
if (!cloudSSHKey) {
|
|
||||||
toast.error("Still generating your SSH key, try again in a moment");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const server = await createServer({
|
|
||||||
name: data.name,
|
|
||||||
description: "",
|
|
||||||
ipAddress: data.ipAddress.trim(),
|
|
||||||
port: 22,
|
|
||||||
username: "root",
|
|
||||||
sshKeyId: cloudSSHKey.sshKeyId,
|
|
||||||
serverType: "deploy",
|
|
||||||
enableDockerCleanup: false,
|
|
||||||
});
|
|
||||||
setCreatedServerId(server.serverId);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error ? error.message : "Error creating the server",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-10">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
|
||||||
Infrastructure
|
|
||||||
</span>
|
|
||||||
<h1 className={titleClassName}>Connect a server.</h1>
|
|
||||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
|
||||||
Dokploy deploys to servers you own. Buy one from any VPS provider
|
|
||||||
(Hetzner, DigitalOcean, Hostinger...) and paste its IP below.
|
|
||||||
</p>
|
|
||||||
<div className="flex items-start gap-2.5 rounded-lg border p-3 max-w-md">
|
|
||||||
<InfoIcon className="size-4 text-muted-foreground shrink-0 mt-0.5" />
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<span className="text-sm font-medium">Requirements</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
To ensure a smooth experience with Dokploy, your server should
|
|
||||||
have at least 1GB of RAM and 30GB of disk space.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!createdServerId ? (
|
|
||||||
<div className="flex flex-col gap-4 max-w-sm">
|
|
||||||
{canCreateMoreServers === false && (
|
|
||||||
<AlertBlock type="warning">
|
|
||||||
You'll need a plan or trial before connecting a server — go back
|
|
||||||
to the "Pick a plan" step.
|
|
||||||
</AlertBlock>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
|
||||||
1. Run this on your server to authorize Dokploy
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="flex-1 min-w-0 truncate rounded bg-muted px-2 py-1.5 text-xs">
|
|
||||||
{cloudSSHKey
|
|
||||||
? `echo "${cloudSSHKey.publicKey}" >> ~/.ssh/authorized_keys`
|
|
||||||
: "Generating..."}
|
|
||||||
</code>
|
|
||||||
{cloudSSHKey && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
copy(
|
|
||||||
`echo "${cloudSSHKey.publicKey}" >> ~/.ssh/authorized_keys`,
|
|
||||||
);
|
|
||||||
toast.success("Copied to clipboard");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CopyIcon className="size-4 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="flex flex-col gap-4"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Server name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="My First Server" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="ipAddress"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>2. IP address</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="192.168.1.100" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
isLoading={isCreating}
|
|
||||||
disabled={!cloudSSHKey || canCreateMoreServers === false}
|
|
||||||
className="mt-2"
|
|
||||||
>
|
|
||||||
Connect server
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-4 w-full max-w-sm">
|
|
||||||
<div className="flex flex-col gap-3 rounded-lg border p-4">
|
|
||||||
{isSettingUp ? (
|
|
||||||
<div className="flex items-center justify-center gap-3 py-4 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="size-4 animate-spin shrink-0" />
|
|
||||||
Setting up your server – installing Docker and dependencies…
|
|
||||||
</div>
|
|
||||||
) : isValidating && !validation ? (
|
|
||||||
<div className="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
Checking your server...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<StatusRow
|
|
||||||
label="Docker installed"
|
|
||||||
ok={validation?.docker?.enabled}
|
|
||||||
pending={!validation?.docker?.enabled && !isReady}
|
|
||||||
/>
|
|
||||||
<StatusRow
|
|
||||||
label="Dokploy network created"
|
|
||||||
ok={validation?.isDokployNetworkInstalled}
|
|
||||||
pending={!validation?.isDokployNetworkInstalled && !isReady}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!isReady && !isSettingUp && (
|
|
||||||
<AlertBlock type="info">
|
|
||||||
Setup runs after the server boots and this page checks for it
|
|
||||||
automatically. You can retry setup, or use "Skip for now" above to
|
|
||||||
continue and come back to this later.
|
|
||||||
</AlertBlock>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1"
|
|
||||||
isLoading={isValidating || isSettingUp}
|
|
||||||
onClick={() => {
|
|
||||||
if (!isReady && !isSettingUp) {
|
|
||||||
setIsSettingUp(true);
|
|
||||||
} else {
|
|
||||||
refetchValidation();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!isReady && !isSettingUp ? "Retry setup" : "Check now"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1"
|
|
||||||
disabled={!isReady || isSettingUp}
|
|
||||||
onClick={onNext}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { GitBranchIcon, PuzzleIcon, ServerIcon } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { displayFont } from "../font";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onNext: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const points = [
|
|
||||||
{
|
|
||||||
icon: GitBranchIcon,
|
|
||||||
title: "Deploy from Git or Docker",
|
|
||||||
description:
|
|
||||||
"Push a repo, a compose file, or a container image and go live.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: PuzzleIcon,
|
|
||||||
title: "One-click templates",
|
|
||||||
description:
|
|
||||||
"WordPress, databases, and dozens of open source apps, pre-wired.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: ServerIcon,
|
|
||||||
title: "Your own servers",
|
|
||||||
description: "Runs on infrastructure you control — no vendor lock-in.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const WelcomeStep = ({ onNext }: Props) => {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-12">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
|
||||||
Welcome
|
|
||||||
</span>
|
|
||||||
<h1
|
|
||||||
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
|
|
||||||
>
|
|
||||||
Let's get your first app <em className="not-italic">live</em>.
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
|
||||||
It just takes a few steps: pick a plan, connect a server, ship
|
|
||||||
something. You'll have a working URL by the end.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl className="flex flex-col divide-y">
|
|
||||||
{points.map((point, index) => (
|
|
||||||
<div key={point.title} className="flex gap-6 py-5 first:pt-0">
|
|
||||||
<dt className="font-mono text-xs text-muted-foreground pt-1 shrink-0 w-6">
|
|
||||||
{String(index + 1).padStart(2, "0")}
|
|
||||||
</dt>
|
|
||||||
<dd className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium flex items-center gap-2">
|
|
||||||
<point.icon className="size-4 text-muted-foreground" />
|
|
||||||
{point.title}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{point.description}
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<Button size="lg" onClick={onNext} className="w-fit px-8">
|
|
||||||
Get started
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { PenBoxIcon, Plus } from "lucide-react";
|
||||||
import { PenBoxIcon, Plus, X } from "lucide-react";
|
import { useEffect, useState } from "react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Logo } from "@/components/shared/logo";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -16,7 +14,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Dropzone } from "@/components/ui/dropzone";
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -27,8 +24,6 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { resizeImage } from "@/utils/image-processing";
|
|
||||||
import { sanitizeSvg } from "@/utils/sanitize-svg";
|
|
||||||
|
|
||||||
const organizationSchema = z.object({
|
const organizationSchema = z.object({
|
||||||
name: z.string().min(1, {
|
name: z.string().min(1, {
|
||||||
@@ -42,24 +37,10 @@ type OrganizationFormValues = z.infer<typeof organizationSchema>;
|
|||||||
interface Props {
|
interface Props {
|
||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
open?: boolean;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddOrganization({
|
export function AddOrganization({ organizationId }: Props) {
|
||||||
organizationId,
|
const [open, setOpen] = useState(false);
|
||||||
open: controlledOpen,
|
|
||||||
onOpenChange: controlledOnOpenChange,
|
|
||||||
}: Props) {
|
|
||||||
const [internalOpen, setInternalOpen] = useState(false);
|
|
||||||
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
const uploadCounter = useRef(0);
|
|
||||||
const isControlled = controlledOpen !== undefined;
|
|
||||||
const open = isControlled ? controlledOpen : internalOpen;
|
|
||||||
const setOpen = isControlled
|
|
||||||
? controlledOnOpenChange || (() => {})
|
|
||||||
: setInternalOpen;
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { data: organization } = api.organization.one.useQuery(
|
const { data: organization } = api.organization.one.useQuery(
|
||||||
{
|
{
|
||||||
@@ -84,18 +65,14 @@ export function AddOrganization({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (organization) {
|
if (organization) {
|
||||||
uploadCounter.current++;
|
|
||||||
setIsUploading(false);
|
|
||||||
form.reset({
|
form.reset({
|
||||||
name: organization.name,
|
name: organization.name,
|
||||||
logo: organization.logo || "",
|
logo: organization.logo || "",
|
||||||
});
|
});
|
||||||
setUploadedFileName(null);
|
|
||||||
}
|
}
|
||||||
}, [organization, form]);
|
}, [organization, form]);
|
||||||
|
|
||||||
const onSubmit = async (values: OrganizationFormValues) => {
|
const onSubmit = async (values: OrganizationFormValues) => {
|
||||||
if (isUploading) return;
|
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
logo: values.logo,
|
logo: values.logo,
|
||||||
@@ -103,7 +80,6 @@ export function AddOrganization({
|
|||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
form.reset();
|
form.reset();
|
||||||
setUploadedFileName(null);
|
|
||||||
toast.success(
|
toast.success(
|
||||||
`Organization ${organizationId ? "updated" : "created"} successfully`,
|
`Organization ${organizationId ? "updated" : "created"} successfully`,
|
||||||
);
|
);
|
||||||
@@ -123,94 +99,8 @@ export function AddOrganization({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileUpload = async (files: FileList | null) => {
|
|
||||||
if (!files || files.length === 0) return;
|
|
||||||
const file = files[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
const currentUploadId = ++uploadCounter.current;
|
|
||||||
setIsUploading(true);
|
|
||||||
|
|
||||||
const allowedTypes = [
|
|
||||||
"image/jpeg",
|
|
||||||
"image/jpg",
|
|
||||||
"image/png",
|
|
||||||
"image/svg+xml",
|
|
||||||
"image/webp",
|
|
||||||
];
|
|
||||||
const fileExtension = file.name.split(".").pop()?.toLowerCase();
|
|
||||||
const allowedExtensions = ["jpg", "jpeg", "png", "svg", "webp"];
|
|
||||||
|
|
||||||
if (
|
|
||||||
!allowedTypes.includes(file.type) &&
|
|
||||||
!allowedExtensions.includes(fileExtension || "")
|
|
||||||
) {
|
|
||||||
toast.error("Only JPG, JPEG, PNG, WEBP, and SVG files are allowed");
|
|
||||||
setIsUploading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > 2 * 1024 * 1024) {
|
|
||||||
toast.error("Image size must be less than 2MB");
|
|
||||||
setIsUploading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSvg = file.type === "image/svg+xml" || fileExtension === "svg";
|
|
||||||
|
|
||||||
if (isSvg) {
|
|
||||||
try {
|
|
||||||
const text = await file.text();
|
|
||||||
const sanitizedDataUrl = sanitizeSvg(text);
|
|
||||||
if (currentUploadId !== uploadCounter.current) return;
|
|
||||||
if (!sanitizedDataUrl) {
|
|
||||||
toast.error("Invalid SVG file");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
form.setValue("logo", sanitizedDataUrl);
|
|
||||||
form.trigger("logo");
|
|
||||||
setUploadedFileName(file.name);
|
|
||||||
} catch (error) {
|
|
||||||
if (currentUploadId === uploadCounter.current) {
|
|
||||||
toast.error("Error processing SVG");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (currentUploadId === uploadCounter.current) {
|
|
||||||
setIsUploading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resize raster images to max 256x256 and convert to WebP to save space
|
|
||||||
try {
|
|
||||||
const resizedDataUrl = await resizeImage(file, 256);
|
|
||||||
if (currentUploadId !== uploadCounter.current) return;
|
|
||||||
form.setValue("logo", resizedDataUrl);
|
|
||||||
form.trigger("logo");
|
|
||||||
setUploadedFileName(file.name);
|
|
||||||
} catch (error) {
|
|
||||||
if (currentUploadId === uploadCounter.current) {
|
|
||||||
toast.error("Error processing image");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (currentUploadId === uploadCounter.current) {
|
|
||||||
setIsUploading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
open={open}
|
|
||||||
onOpenChange={(val) => {
|
|
||||||
if (!val) {
|
|
||||||
uploadCounter.current++;
|
|
||||||
setIsUploading(false);
|
|
||||||
}
|
|
||||||
setOpen(val);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{organizationId ? (
|
{organizationId ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -256,10 +146,8 @@ export function AddOrganization({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="items-center gap-4">
|
<FormItem className="tems-center gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<FormLabel className="text-right">Name</FormLabel>
|
||||||
<FormLabel className="text-right">Name</FormLabel>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Organization name"
|
placeholder="Organization name"
|
||||||
@@ -274,78 +162,23 @@ export function AddOrganization({
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="logo"
|
name="logo"
|
||||||
render={({ field }) => {
|
render={({ field }) => (
|
||||||
const isDataUrl = field.value?.startsWith("data:");
|
<FormItem className="gap-4">
|
||||||
const displayValue = isDataUrl
|
<FormLabel className="text-right">Logo URL</FormLabel>
|
||||||
? uploadedFileName || "Uploaded image"
|
<FormControl>
|
||||||
: field.value || "";
|
<Input
|
||||||
|
placeholder="https://example.com/logo.png"
|
||||||
return (
|
{...field}
|
||||||
<FormItem className="gap-4">
|
value={field.value || ""}
|
||||||
<FormLabel className="text-right">
|
className="col-span-3"
|
||||||
Logo URL or Upload
|
/>
|
||||||
</FormLabel>
|
</FormControl>
|
||||||
<FormControl>
|
<FormMessage className="col-span-3 col-start-2" />
|
||||||
<div className="col-span-3 flex flex-col gap-3">
|
</FormItem>
|
||||||
<div className="flex items-center gap-3">
|
)}
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-md border bg-muted/50 overflow-hidden">
|
|
||||||
{field.value ? (
|
|
||||||
// biome-ignore lint/performance/noImgElement: user uploaded logo preview
|
|
||||||
<img
|
|
||||||
src={field.value}
|
|
||||||
alt="Logo preview"
|
|
||||||
className="size-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Logo className="size-7" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Input
|
|
||||||
placeholder="https://example.com/logo.png"
|
|
||||||
{...field}
|
|
||||||
value={displayValue}
|
|
||||||
readOnly={isDataUrl}
|
|
||||||
onChange={(e) => {
|
|
||||||
uploadCounter.current++;
|
|
||||||
setIsUploading(false);
|
|
||||||
field.onChange(e);
|
|
||||||
if (isDataUrl) setUploadedFileName(null);
|
|
||||||
}}
|
|
||||||
className="w-full pr-8"
|
|
||||||
/>
|
|
||||||
{field.value && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
uploadCounter.current++;
|
|
||||||
setIsUploading(false);
|
|
||||||
form.setValue("logo", "");
|
|
||||||
setUploadedFileName(null);
|
|
||||||
}}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Dropzone
|
|
||||||
dropMessage="Drag & drop a logo or click to upload"
|
|
||||||
accept=".jpg,.jpeg,.png,.svg,.webp,image/jpeg,image/png,image/svg+xml,image/webp"
|
|
||||||
onChange={handleFileUpload}
|
|
||||||
classNameWrapper="border-2 border-dashed border-border hover:border-primary bg-muted/30 hover:bg-muted/50 transition-all rounded-lg"
|
|
||||||
classNameContent="h-32"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage className="col-span-3 col-start-2" />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" isLoading={isPending || isUploading}>
|
<Button type="submit" isLoading={isPending}>
|
||||||
{organizationId ? "Update organization" : "Create organization"}
|
{organizationId ? "Update organization" : "Create organization"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Rocket } from "lucide-react";
|
import { Rocket } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
|
||||||
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
|
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
|
||||||
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
|
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
|
||||||
import {
|
import {
|
||||||
@@ -9,14 +8,6 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
const SUBTAB_VALUES = ["deployments", "queue"] as const;
|
const SUBTAB_VALUES = ["deployments", "queue"] as const;
|
||||||
@@ -35,10 +26,6 @@ export const ShowOverviewDeployments = () => {
|
|||||||
? router.query.subtab
|
? router.query.subtab
|
||||||
: DEFAULT_SUBTAB;
|
: DEFAULT_SUBTAB;
|
||||||
|
|
||||||
const [globalFilter, setGlobalFilter] = useState("");
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
|
||||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
|
||||||
|
|
||||||
const setSubtab = (value: string) => {
|
const setSubtab = (value: string) => {
|
||||||
if (!isValidSubtab(value)) return;
|
if (!isValidSubtab(value)) return;
|
||||||
const { subtab: _current, ...query } = router.query;
|
const { subtab: _current, ...query } = router.query;
|
||||||
@@ -72,50 +59,12 @@ export const ShowOverviewDeployments = () => {
|
|||||||
onValueChange={setSubtab}
|
onValueChange={setSubtab}
|
||||||
className="w-full min-w-0"
|
className="w-full min-w-0"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center gap-y-4 gap-x-3 mt-2">
|
<TabsList className="mt-2">
|
||||||
<TabsList className="self-start shrink-0">
|
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
<TabsTrigger value="queue">Queue</TabsTrigger>
|
||||||
<TabsTrigger value="queue">Queue</TabsTrigger>
|
</TabsList>
|
||||||
</TabsList>
|
|
||||||
{subtab === "deployments" && (
|
|
||||||
<div className="flex flex-wrap lg:flex-nowrap items-center gap-2 lg:ml-auto">
|
|
||||||
<Input
|
|
||||||
placeholder="Search by name, project, environment, server..."
|
|
||||||
value={globalFilter}
|
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
|
||||||
className="max-w-xs"
|
|
||||||
/>
|
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
||||||
<SelectTrigger className="w-[140px]">
|
|
||||||
<SelectValue placeholder="Status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All statuses</SelectItem>
|
|
||||||
<SelectItem value="running">Running</SelectItem>
|
|
||||||
<SelectItem value="done">Done</SelectItem>
|
|
||||||
<SelectItem value="error">Error</SelectItem>
|
|
||||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
|
||||||
<SelectTrigger className="w-[140px]">
|
|
||||||
<SelectValue placeholder="Type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All types</SelectItem>
|
|
||||||
<SelectItem value="application">Application</SelectItem>
|
|
||||||
<SelectItem value="compose">Compose</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<TabsContent value="deployments" className="mt-0 min-w-0 pt-4">
|
<TabsContent value="deployments" className="mt-0 min-w-0 pt-4">
|
||||||
<ShowDeploymentsTable
|
<ShowDeploymentsTable />
|
||||||
globalFilter={globalFilter}
|
|
||||||
statusFilter={statusFilter}
|
|
||||||
typeFilter={typeFilter}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="queue" className="mt-0 pt-4">
|
<TabsContent value="queue" className="mt-0 pt-4">
|
||||||
<ShowQueueTable />
|
<ShowQueueTable />
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Bell,
|
Bell,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
Clock,
|
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FileText,
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -94,7 +93,6 @@ export const ShowBilling = () => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: servers } = api.server.count.useQuery();
|
const { data: servers } = api.server.count.useQuery();
|
||||||
const { data: admin } = api.user.get.useQuery();
|
const { data: admin } = api.user.get.useQuery();
|
||||||
const { data: billingStatus } = api.stripe.getBillingStatus.useQuery();
|
|
||||||
const { data, isPending } = api.stripe.getProducts.useQuery();
|
const { data, isPending } = api.stripe.getProducts.useQuery();
|
||||||
const { mutateAsync: createCheckoutSession } =
|
const { mutateAsync: createCheckoutSession } =
|
||||||
api.stripe.createCheckoutSession.useMutation();
|
api.stripe.createCheckoutSession.useMutation();
|
||||||
@@ -105,67 +103,7 @@ export const ShowBilling = () => {
|
|||||||
api.stripe.upgradeSubscription.useMutation();
|
api.stripe.upgradeSubscription.useMutation();
|
||||||
const { mutateAsync: updateInvoiceNotifications } =
|
const { mutateAsync: updateInvoiceNotifications } =
|
||||||
api.stripe.updateInvoiceNotifications.useMutation();
|
api.stripe.updateInvoiceNotifications.useMutation();
|
||||||
const { mutateAsync: startFreeTrial } =
|
|
||||||
api.stripe.startFreeTrial.useMutation();
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null);
|
|
||||||
const [switchingTrial, setSwitchingTrial] = useState(false);
|
|
||||||
|
|
||||||
const handleSwitchTrialPlan = async (
|
|
||||||
tier: "hobby" | "startup",
|
|
||||||
isAnnualPlan: boolean,
|
|
||||||
label?: string,
|
|
||||||
) => {
|
|
||||||
const planName =
|
|
||||||
label ??
|
|
||||||
`${tier === "startup" ? "Startup" : "Hobby"} ${isAnnualPlan ? "annual" : "monthly"}`;
|
|
||||||
|
|
||||||
const switchPlan = async () => {
|
|
||||||
await upgradeSubscription({
|
|
||||||
tier,
|
|
||||||
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
|
|
||||||
isAnnual: isAnnualPlan,
|
|
||||||
});
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
||||||
await Promise.all([
|
|
||||||
utils.stripe.getBillingStatus.invalidate(),
|
|
||||||
utils.stripe.getProducts.invalidate(),
|
|
||||||
utils.user.get.invalidate(),
|
|
||||||
utils.server.count.invalidate(),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
setSwitchingTrial(true);
|
|
||||||
await toast
|
|
||||||
.promise(switchPlan(), {
|
|
||||||
loading: `Switching to ${planName}...`,
|
|
||||||
success: `Your trial is now on ${planName}`,
|
|
||||||
error: (error) =>
|
|
||||||
error instanceof Error ? error.message : "Error switching plan",
|
|
||||||
})
|
|
||||||
.unwrap()
|
|
||||||
.catch(() => undefined);
|
|
||||||
setSwitchingTrial(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStartTrial = async (tier: "hobby" | "startup") => {
|
|
||||||
setTrialTier(tier);
|
|
||||||
try {
|
|
||||||
await startFreeTrial({ tier });
|
|
||||||
await Promise.all([
|
|
||||||
utils.stripe.getBillingStatus.invalidate(),
|
|
||||||
utils.stripe.getProducts.invalidate(),
|
|
||||||
utils.user.get.invalidate(),
|
|
||||||
]);
|
|
||||||
toast.success("Your 7-day trial has started");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error ? error.message : "Error starting trial",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setTrialTier(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
||||||
const [startupServerQuantity, setStartupServerQuantity] = useState(
|
const [startupServerQuantity, setStartupServerQuantity] = useState(
|
||||||
@@ -212,23 +150,12 @@ export const ShowBilling = () => {
|
|||||||
|
|
||||||
const useNewPricing = data?.hobbyProductId && data?.startupProductId;
|
const useNewPricing = data?.hobbyProductId && data?.startupProductId;
|
||||||
const products = data?.products.filter((product) => {
|
const products = data?.products.filter((product) => {
|
||||||
// @ts-expect-error
|
// @ts-ignore
|
||||||
const interval = product?.default_price?.recurring?.interval;
|
const interval = product?.default_price?.recurring?.interval;
|
||||||
return isAnnual ? interval === "year" : interval === "month";
|
return isAnnual ? interval === "year" : interval === "month";
|
||||||
});
|
});
|
||||||
|
|
||||||
const isEnterpriseCloud = admin?.user.isEnterpriseCloud ?? false;
|
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 maxServers = admin?.user.serversQuantity ?? 1;
|
||||||
const percentage = ((servers ?? 0) / maxServers) * 100;
|
const percentage = ((servers ?? 0) / maxServers) * 100;
|
||||||
const safePercentage = Math.min(percentage, 100);
|
const safePercentage = Math.min(percentage, 100);
|
||||||
@@ -322,126 +249,6 @@ export const ShowBilling = () => {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 w-full mt-6">
|
<div className="flex flex-col gap-4 w-full mt-6">
|
||||||
{!isEnterpriseCloud &&
|
|
||||||
billingStatus &&
|
|
||||||
(billingStatus.plan || billingStatus.isOnTrial) && (
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border p-4 max-w-2xl">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{billingStatus.isOnTrial ? (
|
|
||||||
<Clock className="h-5 w-5 text-primary shrink-0" />
|
|
||||||
) : (
|
|
||||||
<CreditCard className="h-5 w-5 text-primary shrink-0" />
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{billingStatus.isOnTrial
|
|
||||||
? "Free trial"
|
|
||||||
: "Current plan"}
|
|
||||||
</span>
|
|
||||||
<Badge className="capitalize" variant="secondary">
|
|
||||||
{billingStatus.plan ?? "Trial"}
|
|
||||||
</Badge>
|
|
||||||
{billingStatus.plan && (
|
|
||||||
<Badge variant="outline">
|
|
||||||
{billingStatus.isAnnual ? "Annual" : "Monthly"}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{billingStatus.isOnTrial
|
|
||||||
? `${billingStatus.trialDaysRemaining} day${billingStatus.trialDaysRemaining === 1 ? "" : "s"} left${
|
|
||||||
billingStatus.trialEndsAt
|
|
||||||
? ` · ends ${new Date(billingStatus.trialEndsAt).toLocaleDateString()}`
|
|
||||||
: ""
|
|
||||||
}${
|
|
||||||
billingStatus.hasPaymentMethod
|
|
||||||
? " · billing starts automatically"
|
|
||||||
: " · add a card to keep your servers"
|
|
||||||
}`
|
|
||||||
: "You're subscribed and billed automatically."}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{admin?.user.stripeCustomerId && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{billingStatus.isOnTrial &&
|
|
||||||
useNewPricing &&
|
|
||||||
billingStatus.plan &&
|
|
||||||
billingStatus.plan !== "legacy" && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
isLoading={switchingTrial}
|
|
||||||
onClick={() =>
|
|
||||||
handleSwitchTrialPlan(
|
|
||||||
billingStatus.plan === "startup"
|
|
||||||
? "hobby"
|
|
||||||
: "startup",
|
|
||||||
billingStatus.isAnnual,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{billingStatus.plan === "startup"
|
|
||||||
? "Switch to Hobby"
|
|
||||||
: "Switch to Startup"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
isLoading={switchingTrial}
|
|
||||||
onClick={() =>
|
|
||||||
handleSwitchTrialPlan(
|
|
||||||
billingStatus.plan as "hobby" | "startup",
|
|
||||||
!billingStatus.isAnnual,
|
|
||||||
billingStatus.isAnnual
|
|
||||||
? "monthly billing"
|
|
||||||
: "annual billing",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{billingStatus.isAnnual
|
|
||||||
? "Switch to monthly"
|
|
||||||
: "Switch to annual"}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={
|
|
||||||
billingStatus.isOnTrial &&
|
|
||||||
!billingStatus.hasPaymentMethod
|
|
||||||
? "default"
|
|
||||||
: "secondary"
|
|
||||||
}
|
|
||||||
onClick={async () => {
|
|
||||||
const session = await createCustomerPortalSession();
|
|
||||||
window.open(session.url);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{billingStatus.isOnTrial &&
|
|
||||||
!billingStatus.hasPaymentMethod
|
|
||||||
? "Add payment method"
|
|
||||||
: "Manage Subscription"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{canStartTrial && (
|
|
||||||
<div className="flex items-center gap-3 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
|
|
||||||
<Clock className="h-5 w-5 text-primary shrink-0" />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
7-day free trial on any plan
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
No credit card required — pick Hobby or Startup below.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
|
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
|
||||||
<div className="space-y-2 flex flex-col">
|
<div className="space-y-2 flex flex-col">
|
||||||
<h3 className="text-lg font-medium">Servers Plan</h3>
|
<h3 className="text-lg font-medium">Servers Plan</h3>
|
||||||
@@ -1002,7 +809,7 @@ export const ShowBilling = () => {
|
|||||||
"Unlimited Deployments",
|
"Unlimited Deployments",
|
||||||
"Unlimited Databases",
|
"Unlimited Databases",
|
||||||
"Unlimited Applications",
|
"Unlimited Applications",
|
||||||
"Setup 1 Server",
|
"1 Server Included",
|
||||||
"1 Organization",
|
"1 Organization",
|
||||||
"1 User",
|
"1 User",
|
||||||
"2 Environments",
|
"2 Environments",
|
||||||
@@ -1018,71 +825,68 @@ export const ShowBilling = () => {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-6 flex flex-col gap-3">
|
<div className="mt-6 flex flex-col gap-3">
|
||||||
{canSubscribe && (
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-sm text-muted-foreground">
|
||||||
<span className="text-sm text-muted-foreground">
|
Servers:
|
||||||
Servers:
|
</span>
|
||||||
</span>
|
<Button
|
||||||
<Button
|
disabled={hobbyServerQuantity <= 1}
|
||||||
disabled={hobbyServerQuantity <= 1}
|
variant="outline"
|
||||||
variant="outline"
|
size="icon"
|
||||||
size="icon"
|
onClick={() =>
|
||||||
onClick={() =>
|
setHobbyServerQuantity((q) => Math.max(1, q - 1))
|
||||||
setHobbyServerQuantity((q) =>
|
}
|
||||||
Math.max(1, q - 1),
|
>
|
||||||
)
|
<MinusIcon className="h-4 w-4" />
|
||||||
}
|
</Button>
|
||||||
>
|
<NumberInput
|
||||||
<MinusIcon className="h-4 w-4" />
|
value={hobbyServerQuantity}
|
||||||
</Button>
|
onChange={(e) =>
|
||||||
<NumberInput
|
setHobbyServerQuantity(
|
||||||
value={hobbyServerQuantity}
|
Math.max(
|
||||||
onChange={(e) =>
|
1,
|
||||||
setHobbyServerQuantity(
|
Number(
|
||||||
Math.max(
|
(e.target as HTMLInputElement).value,
|
||||||
1,
|
) || 1,
|
||||||
Number(
|
),
|
||||||
(e.target as HTMLInputElement).value,
|
)
|
||||||
) || 1,
|
}
|
||||||
),
|
className="text-center"
|
||||||
)
|
/>
|
||||||
}
|
<Button
|
||||||
className="text-center"
|
variant="outline"
|
||||||
/>
|
size="icon"
|
||||||
<Button
|
onClick={() => setHobbyServerQuantity((q) => q + 1)}
|
||||||
variant="outline"
|
>
|
||||||
size="icon"
|
<PlusIcon className="h-4 w-4" />
|
||||||
onClick={() =>
|
</Button>
|
||||||
setHobbyServerQuantity((q) => q + 1)
|
</div>
|
||||||
}
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
{canStartTrial && (
|
{admin?.user.stripeCustomerId && (
|
||||||
<Button
|
<Button
|
||||||
|
variant="secondary"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
isLoading={trialTier === "hobby"}
|
onClick={async () => {
|
||||||
disabled={trialTier !== null}
|
const session =
|
||||||
onClick={() => handleStartTrial("hobby")}
|
await createCustomerPortalSession();
|
||||||
|
window.open(session.url);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Start 7-day free trial
|
Manage Subscription
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{canSubscribe && (
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
variant={canStartTrial ? "outline" : "default"}
|
|
||||||
onClick={() =>
|
|
||||||
handleCheckout("hobby", data!.hobbyProductId!)
|
|
||||||
}
|
|
||||||
disabled={hobbyServerQuantity < 1}
|
|
||||||
>
|
|
||||||
Get Started
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{!isEnterpriseCloud &&
|
||||||
|
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() =>
|
||||||
|
handleCheckout("hobby", data!.hobbyProductId!)
|
||||||
|
}
|
||||||
|
disabled={hobbyServerQuantity < 1}
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1137,7 +941,7 @@ export const ShowBilling = () => {
|
|||||||
All the features of Hobby, plus…
|
All the features of Hobby, plus…
|
||||||
</li>
|
</li>
|
||||||
{[
|
{[
|
||||||
"Setup up to 3 Servers",
|
"3 Servers Included",
|
||||||
"3 Organizations",
|
"3 Organizations",
|
||||||
"Unlimited Users",
|
"Unlimited Users",
|
||||||
"Unlimited Environments",
|
"Unlimited Environments",
|
||||||
@@ -1155,83 +959,85 @@ export const ShowBilling = () => {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-6 flex flex-col gap-3">
|
<div className="mt-6 flex flex-col gap-3">
|
||||||
{canSubscribe && (
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-col gap-2">
|
<span className="text-sm font-medium text-foreground">
|
||||||
<span className="text-sm font-medium text-foreground">
|
Servers (min. {STARTUP_SERVERS_INCLUDED} included)
|
||||||
Servers (min. {STARTUP_SERVERS_INCLUDED} included)
|
</span>
|
||||||
</span>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
disabled={
|
|
||||||
startupServerQuantity <=
|
|
||||||
STARTUP_SERVERS_INCLUDED
|
|
||||||
}
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() =>
|
|
||||||
setStartupServerQuantity((q) =>
|
|
||||||
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MinusIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<NumberInput
|
|
||||||
value={startupServerQuantity}
|
|
||||||
onChange={(e) =>
|
|
||||||
setStartupServerQuantity(
|
|
||||||
Math.max(
|
|
||||||
STARTUP_SERVERS_INCLUDED,
|
|
||||||
Number(
|
|
||||||
(e.target as HTMLInputElement).value,
|
|
||||||
) || STARTUP_SERVERS_INCLUDED,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="h-8 text-center"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() =>
|
|
||||||
setStartupServerQuantity((q) => q + 1)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col gap-2 w-full">
|
|
||||||
{canStartTrial && (
|
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
disabled={
|
||||||
isLoading={trialTier === "startup"}
|
startupServerQuantity <=
|
||||||
disabled={trialTier !== null}
|
STARTUP_SERVERS_INCLUDED
|
||||||
onClick={() => handleStartTrial("startup")}
|
}
|
||||||
>
|
variant="outline"
|
||||||
Start 7-day free trial
|
size="icon"
|
||||||
</Button>
|
className="h-8 w-8"
|
||||||
)}
|
|
||||||
{canSubscribe && (
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
variant={canStartTrial ? "outline" : "default"}
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleCheckout(
|
setStartupServerQuantity((q) =>
|
||||||
"startup",
|
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
|
||||||
data!.startupProductId!,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
disabled={
|
>
|
||||||
startupServerQuantity < STARTUP_SERVERS_INCLUDED
|
<MinusIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<NumberInput
|
||||||
|
value={startupServerQuantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
setStartupServerQuantity(
|
||||||
|
Math.max(
|
||||||
|
STARTUP_SERVERS_INCLUDED,
|
||||||
|
Number(
|
||||||
|
(e.target as HTMLInputElement).value,
|
||||||
|
) || STARTUP_SERVERS_INCLUDED,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-8 text-center"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() =>
|
||||||
|
setStartupServerQuantity((q) => q + 1)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Get Started
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
{admin?.user.stripeCustomerId && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full"
|
||||||
|
onClick={async () => {
|
||||||
|
const session =
|
||||||
|
await createCustomerPortalSession();
|
||||||
|
window.open(session.url);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Manage Subscription
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{!isEnterpriseCloud &&
|
||||||
|
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() =>
|
||||||
|
handleCheckout(
|
||||||
|
"startup",
|
||||||
|
data!.startupProductId!,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
startupServerQuantity <
|
||||||
|
STARTUP_SERVERS_INCLUDED
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1423,17 +1229,31 @@ export const ShowBilling = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 mt-4 w-full">
|
<div className="flex flex-col gap-2 mt-4 w-full">
|
||||||
{canSubscribe && (
|
{admin?.user.stripeCustomerId && (
|
||||||
<Button
|
<Button
|
||||||
|
variant="secondary"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
handleCheckout("legacy", product.id);
|
const session =
|
||||||
|
await createCustomerPortalSession();
|
||||||
|
window.open(session.url);
|
||||||
}}
|
}}
|
||||||
disabled={hobbyServerQuantity < 1}
|
|
||||||
>
|
>
|
||||||
Subscribe
|
Manage Subscription
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{!isEnterpriseCloud &&
|
||||||
|
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={async () => {
|
||||||
|
handleCheckout("legacy", product.id);
|
||||||
|
}}
|
||||||
|
disabled={hobbyServerQuantity < 1}
|
||||||
|
>
|
||||||
|
Subscribe
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ShowBilling } from "@/components/dashboard/settings/billing/show-billing";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const ShowWelcomeDokploy = () => {
|
||||||
|
const { data } = api.user.get.useQuery();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: isCloud, isPending } = api.settings.isCloud.useQuery();
|
||||||
|
|
||||||
|
if (!isCloud || data?.role !== "admin") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!isPending &&
|
||||||
|
isCloud &&
|
||||||
|
!localStorage.getItem("hasSeenCloudWelcomeModal") &&
|
||||||
|
data?.role === "owner"
|
||||||
|
) {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
}, [isCloud, isPending]);
|
||||||
|
|
||||||
|
const handleClose = (isOpen: boolean) => {
|
||||||
|
if (data?.role === "owner") {
|
||||||
|
setOpen(isOpen);
|
||||||
|
if (!isOpen) {
|
||||||
|
localStorage.setItem("hasSeenCloudWelcomeModal", "true"); // Establece el flag al cerrar el modal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl font-semibold text-center">
|
||||||
|
Welcome to Dokploy Cloud 🎉
|
||||||
|
</DialogTitle>
|
||||||
|
<p className="text-center text-sm text-muted-foreground mt-2">
|
||||||
|
Unlock powerful features to streamline your deployments and manage
|
||||||
|
projects effortlessly.
|
||||||
|
</p>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="mt-4 space-y-3 text-sm text-primary ">
|
||||||
|
<ShowBilling />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -44,18 +44,6 @@ const providerLabels = {
|
|||||||
cloudflare: "Cloudflare",
|
cloudflare: "Cloudflare",
|
||||||
route53: "AWS Route53",
|
route53: "AWS Route53",
|
||||||
porkbun: "Porkbun",
|
porkbun: "Porkbun",
|
||||||
infomaniak: "Infomaniak",
|
|
||||||
ovh: "OVHcloud",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const ovhEndpointLabels = {
|
|
||||||
"ovh-eu": "OVHcloud Europe",
|
|
||||||
"ovh-ca": "OVHcloud Canada",
|
|
||||||
"ovh-us": "OVHcloud US",
|
|
||||||
"kimsufi-eu": "Kimsufi Europe",
|
|
||||||
"kimsufi-ca": "Kimsufi Canada",
|
|
||||||
"soyoustart-eu": "So you Start Europe",
|
|
||||||
"soyoustart-ca": "So you Start Canada",
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type ProviderType = keyof typeof providerLabels;
|
type ProviderType = keyof typeof providerLabels;
|
||||||
@@ -67,27 +55,12 @@ const DnsProviderSchema = z.object({
|
|||||||
.regex(/^[a-zA-Z0-9_-]+$/, {
|
.regex(/^[a-zA-Z0-9_-]+$/, {
|
||||||
message: "Only letters, numbers, dashes and underscores",
|
message: "Only letters, numbers, dashes and underscores",
|
||||||
}),
|
}),
|
||||||
providerType: z.enum([
|
providerType: z.enum(["cloudflare", "route53", "porkbun"]),
|
||||||
"cloudflare",
|
|
||||||
"route53",
|
|
||||||
"porkbun",
|
|
||||||
"infomaniak",
|
|
||||||
"ovh",
|
|
||||||
]),
|
|
||||||
apiToken: z.string(),
|
apiToken: z.string(),
|
||||||
accessKeyId: z.string(),
|
accessKeyId: z.string(),
|
||||||
secretAccessKey: z.string(),
|
secretAccessKey: z.string(),
|
||||||
apiKey: z.string(),
|
apiKey: z.string(),
|
||||||
secretApiKey: z.string(),
|
secretApiKey: z.string(),
|
||||||
endpoint: z.enum(
|
|
||||||
Object.keys(ovhEndpointLabels) as [
|
|
||||||
keyof typeof ovhEndpointLabels,
|
|
||||||
...(keyof typeof ovhEndpointLabels)[],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
applicationKey: z.string(),
|
|
||||||
applicationSecret: z.string(),
|
|
||||||
consumerKey: z.string(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
|
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
|
||||||
@@ -100,10 +73,6 @@ const defaultValues: DnsProviderForm = {
|
|||||||
secretAccessKey: "",
|
secretAccessKey: "",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
secretApiKey: "",
|
secretApiKey: "",
|
||||||
endpoint: "ovh-eu",
|
|
||||||
applicationKey: "",
|
|
||||||
applicationSecret: "",
|
|
||||||
consumerKey: "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildConfig = (data: DnsProviderForm) => {
|
const buildConfig = (data: DnsProviderForm) => {
|
||||||
@@ -125,19 +94,6 @@ const buildConfig = (data: DnsProviderForm) => {
|
|||||||
apiKey: data.apiKey,
|
apiKey: data.apiKey,
|
||||||
secretApiKey: data.secretApiKey,
|
secretApiKey: data.secretApiKey,
|
||||||
};
|
};
|
||||||
case "infomaniak":
|
|
||||||
return {
|
|
||||||
providerType: "infomaniak" as const,
|
|
||||||
apiToken: data.apiToken,
|
|
||||||
};
|
|
||||||
case "ovh":
|
|
||||||
return {
|
|
||||||
providerType: "ovh" as const,
|
|
||||||
endpoint: data.endpoint,
|
|
||||||
applicationKey: data.applicationKey,
|
|
||||||
applicationSecret: data.applicationSecret,
|
|
||||||
consumerKey: data.consumerKey,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -199,15 +155,6 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
|||||||
apiKey: provider.config.apiKey,
|
apiKey: provider.config.apiKey,
|
||||||
secretApiKey: provider.config.secretApiKey,
|
secretApiKey: provider.config.secretApiKey,
|
||||||
}),
|
}),
|
||||||
...(provider.config.providerType === "infomaniak" && {
|
|
||||||
apiToken: provider.config.apiToken,
|
|
||||||
}),
|
|
||||||
...(provider.config.providerType === "ovh" && {
|
|
||||||
endpoint: provider.config.endpoint,
|
|
||||||
applicationKey: provider.config.applicationKey,
|
|
||||||
applicationSecret: provider.config.applicationSecret,
|
|
||||||
consumerKey: provider.config.consumerKey,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
} else if (!dnsProviderId) {
|
} else if (!dnsProviderId) {
|
||||||
form.reset(defaultValues);
|
form.reset(defaultValues);
|
||||||
@@ -436,118 +383,6 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{providerType === "infomaniak" && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="apiToken"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>API Token</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="password" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Create a token at manager.infomaniak.com with the{" "}
|
|
||||||
<code>domain:read</code>, <code>dns:read</code> and{" "}
|
|
||||||
<code>dns:write</code> scopes.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{providerType === "ovh" && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="endpoint"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>API Endpoint</FormLabel>
|
|
||||||
<Select
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
value={field.value}
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select an endpoint" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
{Object.entries(ovhEndpointLabels).map(
|
|
||||||
([value, label]) => (
|
|
||||||
<SelectItem key={value} value={value}>
|
|
||||||
{label}
|
|
||||||
</SelectItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="applicationKey"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Application Key</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="applicationSecret"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Application Secret</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="password" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="consumerKey"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Consumer Key</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="password" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Create the three keys at once on
|
|
||||||
api.ovh.com/createToken, with exactly these five rights:
|
|
||||||
<br />
|
|
||||||
<code>GET /domain/zone</code>
|
|
||||||
<br />
|
|
||||||
<code>GET /domain/zone/*</code>
|
|
||||||
<br />
|
|
||||||
<code>POST /domain/zone/*</code>
|
|
||||||
<br />
|
|
||||||
<code>PUT /domain/zone/*</code>
|
|
||||||
<br />
|
|
||||||
<code>DELETE /domain/zone/*</code>
|
|
||||||
<br />
|
|
||||||
The first one lists your zones and has to be granted on
|
|
||||||
its own: OVH matches rights per exact path, so{" "}
|
|
||||||
<code>/domain/zone/*</code> does not cover it.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
|
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ import { HandleDnsProvider } from "./handle-dns-provider";
|
|||||||
const providerLabels: Record<string, string> = {
|
const providerLabels: Record<string, string> = {
|
||||||
cloudflare: "Cloudflare",
|
cloudflare: "Cloudflare",
|
||||||
route53: "AWS Route53",
|
route53: "AWS Route53",
|
||||||
porkbun: "Porkbun",
|
|
||||||
infomaniak: "Infomaniak",
|
|
||||||
ovh: "OVHcloud",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ShowDnsProviders = () => {
|
export const ShowDnsProviders = () => {
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
import { Loader2, Trash2 } from "lucide-react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
const errorMessage = (error: unknown, fallback: string) =>
|
|
||||||
error instanceof Error ? error.message : fallback;
|
|
||||||
|
|
||||||
export const DeleteAccount = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [step, setStep] = useState<"request" | "confirm">("request");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [code, setCode] = useState("");
|
|
||||||
const [expiresInMinutes, setExpiresInMinutes] = useState(10);
|
|
||||||
const { data } = api.user.get.useQuery();
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
|
||||||
const { data: hasPassword } = api.user.hasPassword.useQuery();
|
|
||||||
const requestCode = api.user.requestAccountDeletionCode.useMutation();
|
|
||||||
const deleteAccount = api.user.deleteAccount.useMutation();
|
|
||||||
|
|
||||||
const email = data?.user?.email ?? "";
|
|
||||||
const isBusy = requestCode.isPending || deleteAccount.isPending;
|
|
||||||
|
|
||||||
if (!isCloud || !data || data.role !== "owner" || hasPassword === undefined) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
setStep("request");
|
|
||||||
setPassword("");
|
|
||||||
setCode("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRequestCode = async () => {
|
|
||||||
try {
|
|
||||||
const result = await requestCode.mutateAsync(
|
|
||||||
hasPassword ? { password } : {},
|
|
||||||
);
|
|
||||||
setExpiresInMinutes(result.expiresInMinutes);
|
|
||||||
setCode("");
|
|
||||||
setStep("confirm");
|
|
||||||
toast.success(`We sent a confirmation code to ${email}`);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(errorMessage(error, "Error sending the confirmation code"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
try {
|
|
||||||
await deleteAccount.mutateAsync({ code });
|
|
||||||
toast.success("Your account has been deleted");
|
|
||||||
await authClient.signOut().catch(() => undefined);
|
|
||||||
router.push("/");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(errorMessage(error, "Error deleting account"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="bg-transparent border-destructive/40">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-xl flex items-center gap-2 text-destructive">
|
|
||||||
<Trash2 className="size-5" />
|
|
||||||
Delete account
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Permanently remove your account and all the data associated with it.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col gap-4">
|
|
||||||
<ul className="text-sm text-muted-foreground list-disc pl-5 space-y-1">
|
|
||||||
<li>
|
|
||||||
Active subscriptions are cancelled immediately. Invoices already
|
|
||||||
issued are kept for accounting.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Every organization you own is deleted, including its projects,
|
|
||||||
services, environments, domains and server connections.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
In organizations you belong to but do not own, only your membership,
|
|
||||||
API keys and the Git providers you connected are removed.
|
|
||||||
Applications using those providers keep running but must be
|
|
||||||
reconnected to redeploy.
|
|
||||||
</li>
|
|
||||||
<li>Containers already running on your servers are not touched.</li>
|
|
||||||
<li>
|
|
||||||
We will email a confirmation code to{" "}
|
|
||||||
<span className="font-medium text-foreground">{email}</span> before
|
|
||||||
anything is deleted.
|
|
||||||
</li>
|
|
||||||
<li>This action cannot be undone.</li>
|
|
||||||
</ul>
|
|
||||||
<div>
|
|
||||||
<AlertDialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(next) => {
|
|
||||||
setOpen(next);
|
|
||||||
if (!next) reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button variant="destructive">Delete account</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete your account?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{step === "request"
|
|
||||||
? `This will permanently delete your account and every organization you own. We will send a confirmation code to ${email}.`
|
|
||||||
: `Enter the ${expiresInMinutes}-minute code we sent to ${email} to confirm the deletion.`}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{step === "request" && hasPassword && (
|
|
||||||
<>
|
|
||||||
<Label htmlFor="delete-account-password">Password</Label>
|
|
||||||
<Input
|
|
||||||
id="delete-account-password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
placeholder="Enter your password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{step === "confirm" && (
|
|
||||||
<>
|
|
||||||
<Label htmlFor="delete-account-code">
|
|
||||||
Confirmation code
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="delete-account-code"
|
|
||||||
inputMode="numeric"
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
maxLength={6}
|
|
||||||
placeholder="123456"
|
|
||||||
value={code}
|
|
||||||
onChange={(e) =>
|
|
||||||
setCode(e.target.value.replace(/\D/g, ""))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="link"
|
|
||||||
size="sm"
|
|
||||||
className="self-start px-0"
|
|
||||||
disabled={isBusy}
|
|
||||||
onClick={handleRequestCode}
|
|
||||||
>
|
|
||||||
Resend code
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel disabled={isBusy}>Cancel</AlertDialogCancel>
|
|
||||||
{step === "request" ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
disabled={isBusy || (hasPassword && password.length === 0)}
|
|
||||||
onClick={handleRequestCode}
|
|
||||||
>
|
|
||||||
{requestCode.isPending && (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
)}
|
|
||||||
Send confirmation code
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
disabled={isBusy || code.length !== 6}
|
|
||||||
onClick={handleDelete}
|
|
||||||
>
|
|
||||||
{deleteAccount.isPending && (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
)}
|
|
||||||
Delete account
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+19
-17
@@ -1,4 +1,6 @@
|
|||||||
|
import { Activity } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -13,31 +15,31 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
children?: React.ReactNode;
|
asButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShowServerActions = ({ serverId, children }: Props) => {
|
export const ShowServerActions = ({ serverId, asButton = false }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
{children ? (
|
{asButton ? (
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
||||||
) : (
|
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<DropdownMenuItem
|
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||||
className="w-full cursor-pointer"
|
<Activity className="h-4 w-4" />
|
||||||
onSelect={(e) => {
|
</Button>
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
View Actions
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer"
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View Actions
|
||||||
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DialogContent
|
<DialogContent className="sm:max-w-xl">
|
||||||
className="sm:max-w-xl"
|
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<DialogTitle className="text-xl">Web server settings</DialogTitle>
|
<DialogTitle className="text-xl">Web server settings</DialogTitle>
|
||||||
<DialogDescription>Reload or clean the web server.</DialogDescription>
|
<DialogDescription>Reload or clean the web server.</DialogDescription>
|
||||||
|
|||||||
@@ -121,10 +121,7 @@ export const DeleteServerModal = ({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
<DialogContent
|
<DialogContent className="max-w-lg">
|
||||||
className="max-w-lg"
|
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete Server</DialogTitle>
|
<DialogTitle>Delete Server</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { Pencil, PlusIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -61,10 +61,10 @@ type Schema = z.infer<typeof Schema>;
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
children?: React.ReactNode;
|
asButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HandleServers = ({ serverId, children }: Props) => {
|
export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { data: canCreateMoreServers, refetch } =
|
const { data: canCreateMoreServers, refetch } =
|
||||||
@@ -141,19 +141,24 @@ export const HandleServers = ({ serverId, children }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
{children ? (
|
{serverId ? (
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
asButton ? (
|
||||||
) : serverId ? (
|
<DialogTrigger asChild>
|
||||||
<DialogTrigger asChild>
|
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
) : (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer "
|
className="w-full cursor-pointer "
|
||||||
onSelect={(e) => {
|
onSelect={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setIsOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Edit Server
|
Edit Server
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
)
|
||||||
) : (
|
) : (
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="cursor-pointer space-x-3">
|
<Button className="cursor-pointer space-x-3">
|
||||||
@@ -162,10 +167,7 @@ export const HandleServers = ({ serverId, children }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
)}
|
)}
|
||||||
<DialogContent
|
<DialogContent className="sm:max-w-3xl ">
|
||||||
className="sm:max-w-3xl "
|
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
|
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ import { ValidateServer } from "./validate-server";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
children?: React.ReactNode;
|
asButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SetupServer = ({ serverId, children }: Props) => {
|
export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { data: server } = api.server.one.useQuery(
|
const { data: server } = api.server.one.useQuery(
|
||||||
{
|
{
|
||||||
@@ -81,19 +81,24 @@ export const SetupServer = ({ serverId, children }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
{children ? (
|
{asButton ? (
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
||||||
) : (
|
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="w-full cursor-pointer " size="sm">
|
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||||
Setup Server <Settings className="size-4" />
|
<Settings className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className="w-full cursor-pointer "
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Setup Server <Settings className="size-4" />
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
<DialogContent
|
<DialogContent className="sm:max-w-4xl ">
|
||||||
className="sm:max-w-4xl "
|
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Stethoscope } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ShowHealth } from "@/components/dashboard/docker/health/show-health";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowHealthModal = ({ serverId }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||||
|
<Stethoscope className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-5xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<div className="flex gap-4 py-4 w-full">
|
||||||
|
<ShowHealth serverId={serverId} />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,27 +7,19 @@ import { ShowPaidMonitoring } from "../../monitoring/paid/servers/show-paid-moni
|
|||||||
interface Props {
|
interface Props {
|
||||||
url: string;
|
url: string;
|
||||||
token: string;
|
token: string;
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShowMonitoringModal = ({ url, token, children }: Props) => {
|
export const ShowMonitoringModal = ({ url, token }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
{children ? (
|
<DialogTrigger asChild>
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||||
) : (
|
<BarChartHorizontalBigIcon className="h-4 w-4" />
|
||||||
<DialogTrigger asChild>
|
</Button>
|
||||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
</DialogTrigger>
|
||||||
<BarChartHorizontalBigIcon className="h-4 w-4" />
|
<DialogContent className="sm:max-w-7xl ">
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
)}
|
|
||||||
<DialogContent
|
|
||||||
className="sm:max-w-7xl "
|
|
||||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<div className="flex gap-4 py-4 w-full">
|
<div className="flex gap-4 py-4 w-full">
|
||||||
<ShowPaidMonitoring BASE_URL={url} token={token} />
|
<ShowPaidMonitoring BASE_URL={url} token={token} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import {
|
import {
|
||||||
Activity,
|
|
||||||
BarChartHorizontalBigIcon,
|
|
||||||
Clock,
|
Clock,
|
||||||
Key,
|
Key,
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
Network,
|
Network,
|
||||||
Pencil,
|
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
Settings,
|
|
||||||
Terminal,
|
Terminal,
|
||||||
Trash2,
|
Trash2,
|
||||||
User,
|
User,
|
||||||
@@ -37,6 +33,7 @@ import { ShowServerActions } from "./actions/show-server-actions";
|
|||||||
import { DeleteServerModal } from "./delete-server-modal";
|
import { DeleteServerModal } from "./delete-server-modal";
|
||||||
import { HandleServers } from "./handle-servers";
|
import { HandleServers } from "./handle-servers";
|
||||||
import { SetupServer } from "./setup-server";
|
import { SetupServer } from "./setup-server";
|
||||||
|
import { ShowHealthModal } from "./show-health-modal";
|
||||||
import { ShowMonitoringModal } from "./show-monitoring-modal";
|
import { ShowMonitoringModal } from "./show-monitoring-modal";
|
||||||
import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription";
|
import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription";
|
||||||
|
|
||||||
@@ -119,13 +116,10 @@ export const ShowServers = () => {
|
|||||||
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
|
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between gap-2 min-w-0">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<ServerIcon className="size-5 shrink-0 text-muted-foreground" />
|
<ServerIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||||
<CardTitle
|
<CardTitle className="text-lg wrap-break-word min-w-0">
|
||||||
className="text-lg truncate min-w-0"
|
|
||||||
title={server.name}
|
|
||||||
>
|
|
||||||
{server.name}
|
{server.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,19 +222,11 @@ export const ShowServers = () => {
|
|||||||
<div className="flex items-center gap-2 pt-3 border-t mt-auto flex-wrap">
|
<div className="flex items-center gap-2 pt-3 border-t mt-auto flex-wrap">
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex items-center gap-2 w-full">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<SetupServer
|
<TooltipTrigger asChild>
|
||||||
serverId={server.serverId}
|
<SetupServer
|
||||||
>
|
serverId={server.serverId}
|
||||||
<TooltipTrigger asChild>
|
/>
|
||||||
<Button
|
</TooltipTrigger>
|
||||||
className="w-full cursor-pointer "
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Setup Server{" "}
|
|
||||||
<Settings className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
</SetupServer>
|
|
||||||
<TooltipContent
|
<TooltipContent
|
||||||
className="max-w-xs"
|
className="max-w-xs"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
@@ -263,20 +249,22 @@ export const ShowServers = () => {
|
|||||||
{server.sshKeyId &&
|
{server.sshKeyId &&
|
||||||
permissions?.server.terminal && (
|
permissions?.server.terminal && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TerminalModal
|
<TooltipTrigger asChild>
|
||||||
serverId={server.serverId}
|
<div>
|
||||||
asButton={true}
|
<TerminalModal
|
||||||
>
|
serverId={server.serverId}
|
||||||
<TooltipTrigger asChild>
|
asButton={true}
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-9 w-9"
|
|
||||||
>
|
>
|
||||||
<Terminal className="h-4 w-4" />
|
<Button
|
||||||
</Button>
|
variant="outline"
|
||||||
</TooltipTrigger>
|
size="icon"
|
||||||
</TerminalModal>
|
className="h-9 w-9"
|
||||||
|
>
|
||||||
|
<Terminal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TerminalModal>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Terminal</p>
|
<p>Terminal</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
@@ -284,19 +272,14 @@ export const ShowServers = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<HandleServers
|
<TooltipTrigger asChild>
|
||||||
serverId={server.serverId}
|
<div>
|
||||||
>
|
<HandleServers
|
||||||
<TooltipTrigger asChild>
|
serverId={server.serverId}
|
||||||
<Button
|
asButton={true}
|
||||||
variant="outline"
|
/>
|
||||||
size="icon"
|
</div>
|
||||||
className="h-9 w-9"
|
</TooltipTrigger>
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
</HandleServers>
|
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Edit Server</p>
|
<p>Edit Server</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
@@ -304,19 +287,14 @@ export const ShowServers = () => {
|
|||||||
|
|
||||||
{server.sshKeyId && !isBuildServer && (
|
{server.sshKeyId && !isBuildServer && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<ShowServerActions
|
<TooltipTrigger asChild>
|
||||||
serverId={server.serverId}
|
<div>
|
||||||
>
|
<ShowServerActions
|
||||||
<TooltipTrigger asChild>
|
serverId={server.serverId}
|
||||||
<Button
|
asButton={true}
|
||||||
variant="outline"
|
/>
|
||||||
size="icon"
|
</div>
|
||||||
className="h-9 w-9"
|
</TooltipTrigger>
|
||||||
>
|
|
||||||
<Activity className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
</ShowServerActions>
|
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Web Server Actions</p>
|
<p>Web Server Actions</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
@@ -327,47 +305,61 @@ export const ShowServers = () => {
|
|||||||
server.sshKeyId &&
|
server.sshKeyId &&
|
||||||
!isBuildServer && (
|
!isBuildServer && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<ShowMonitoringModal
|
<TooltipTrigger asChild>
|
||||||
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
|
<div>
|
||||||
token={
|
<ShowMonitoringModal
|
||||||
server?.metricsConfig?.server
|
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
|
||||||
?.token
|
token={
|
||||||
}
|
server?.metricsConfig
|
||||||
>
|
?.server?.token
|
||||||
<TooltipTrigger asChild>
|
}
|
||||||
<Button
|
/>
|
||||||
variant="outline"
|
</div>
|
||||||
size="icon"
|
</TooltipTrigger>
|
||||||
className="h-9 w-9"
|
|
||||||
>
|
|
||||||
<BarChartHorizontalBigIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
</ShowMonitoringModal>
|
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Monitoring</p>
|
<p>Monitoring</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{permissions?.docker.read &&
|
||||||
|
permissions?.server.read &&
|
||||||
|
server.sshKeyId &&
|
||||||
|
!isBuildServer && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div>
|
||||||
|
<ShowHealthModal
|
||||||
|
serverId={server.serverId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Health</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{permissions?.server.delete && (
|
{permissions?.server.delete && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<DeleteServerModal
|
<TooltipTrigger asChild>
|
||||||
serverId={server.serverId}
|
<div>
|
||||||
serverName={server.name}
|
<DeleteServerModal
|
||||||
>
|
serverId={server.serverId}
|
||||||
<TooltipTrigger asChild>
|
serverName={server.name}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
</TooltipTrigger>
|
size="icon"
|
||||||
</DeleteServerModal>
|
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DeleteServerModal>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Delete Server</p>
|
<p>Delete Server</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const Schema = z.object({
|
||||||
|
name: z.string().min(1, {
|
||||||
|
message: "Name is required",
|
||||||
|
}),
|
||||||
|
description: z.string().optional(),
|
||||||
|
ipAddress: z.string().min(1, {
|
||||||
|
message: "IP Address is required",
|
||||||
|
}),
|
||||||
|
port: z.number().optional(),
|
||||||
|
username: z.string().optional(),
|
||||||
|
sshKeyId: z.string().min(1, {
|
||||||
|
message: "SSH Key is required",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Schema = z.infer<typeof Schema>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
stepper: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateServer = ({ stepper }: Props) => {
|
||||||
|
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||||
|
const [isOpen, _setIsOpen] = useState(false);
|
||||||
|
const { data: canCreateMoreServers, refetch } =
|
||||||
|
api.stripe.canCreateMoreServers.useQuery();
|
||||||
|
const { mutateAsync } = api.server.create.useMutation();
|
||||||
|
const cloudSSHKey = sshKeys?.find(
|
||||||
|
(sshKey) => sshKey.name === "dokploy-cloud-ssh-key",
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = useForm<Schema>({
|
||||||
|
defaultValues: {
|
||||||
|
description: "Dokploy Cloud Server",
|
||||||
|
name: "My First Server",
|
||||||
|
ipAddress: "",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
sshKeyId: cloudSSHKey?.sshKeyId || "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(Schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset({
|
||||||
|
description: "Dokploy Cloud Server",
|
||||||
|
name: "My First Server",
|
||||||
|
ipAddress: "",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
sshKeyId: cloudSSHKey?.sshKeyId || "",
|
||||||
|
});
|
||||||
|
}, [form, form.reset, form.formState.isSubmitSuccessful, sshKeys]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refetch();
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: Schema) => {
|
||||||
|
await mutateAsync({
|
||||||
|
name: data.name,
|
||||||
|
description: data.description || "",
|
||||||
|
ipAddress: data.ipAddress?.trim() || "",
|
||||||
|
port: data.port || 22,
|
||||||
|
username: data.username || "root",
|
||||||
|
sshKeyId: data.sshKeyId || "",
|
||||||
|
serverType: "deploy",
|
||||||
|
})
|
||||||
|
.then(async (_data) => {
|
||||||
|
toast.success("Server Created");
|
||||||
|
stepper.next();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error creating a server");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Card className="bg-background flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2 pt-5 px-4">
|
||||||
|
{!canCreateMoreServers && (
|
||||||
|
<AlertBlock type="warning" className="mt-2">
|
||||||
|
You cannot create more servers,{" "}
|
||||||
|
<Link href="/dashboard/settings/billing" className="text-primary">
|
||||||
|
Please upgrade your plan
|
||||||
|
</Link>
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="flex flex-col">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="hook-form-add-server"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="grid w-full gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4 ">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Hostinger Server" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="This server is for databases..."
|
||||||
|
className="resize-none"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="sshKeyId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Select a SSH Key</FormLabel>
|
||||||
|
{!cloudSSHKey && (
|
||||||
|
<AlertBlock>
|
||||||
|
Looks like you didn't have the SSH Key yet, you can create
|
||||||
|
one{" "}
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings/ssh-keys"
|
||||||
|
className="text-primary"
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</Link>
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a SSH Key" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{sshKeys?.map((sshKey) => (
|
||||||
|
<SelectItem
|
||||||
|
key={sshKey.sshKeyId}
|
||||||
|
value={sshKey.sshKeyId}
|
||||||
|
>
|
||||||
|
{sshKey.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectLabel>SSH Keys ({sshKeys?.length})</SelectLabel>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="ipAddress"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>IP Address</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="192.168.1.100" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="port"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Port</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="22"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (value === "") {
|
||||||
|
field.onChange(0);
|
||||||
|
} else {
|
||||||
|
const number = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isNaN(number)) {
|
||||||
|
field.onChange(number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Username</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="root" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
isLoading={form.formState.isSubmitting}
|
||||||
|
disabled={!canCreateMoreServers}
|
||||||
|
form="hook-form-add-server"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import copy from "copy-to-clipboard";
|
||||||
|
import { CopyIcon, ExternalLinkIcon, Loader2 } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const CreateSSHKey = () => {
|
||||||
|
const { data, refetch } = api.sshKey.all.useQuery();
|
||||||
|
const generateMutation = api.sshKey.generate.useMutation();
|
||||||
|
const { mutateAsync, isPending } = api.sshKey.create.useMutation();
|
||||||
|
const hasCreatedKey = useRef(false);
|
||||||
|
const [selectedOption, setSelectedOption] = useState<"manual" | "provider">(
|
||||||
|
"manual",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cloudSSHKey = data?.find(
|
||||||
|
(sshKey) => sshKey.name === "dokploy-cloud-ssh-key",
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const createKey = async () => {
|
||||||
|
if (!data || cloudSSHKey || hasCreatedKey.current || isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasCreatedKey.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keys = await generateMutation.mutateAsync({
|
||||||
|
type: "rsa",
|
||||||
|
});
|
||||||
|
await mutateAsync({
|
||||||
|
name: "dokploy-cloud-ssh-key",
|
||||||
|
description: "Used on Dokploy Cloud",
|
||||||
|
privateKey: keys.privateKey,
|
||||||
|
publicKey: keys.publicKey,
|
||||||
|
organizationId: "",
|
||||||
|
});
|
||||||
|
await refetch();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating SSH key:", error);
|
||||||
|
hasCreatedKey.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
createKey();
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full bg-transparent">
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid w-full gap-4 pt-4">
|
||||||
|
{isPending || !cloudSSHKey ? (
|
||||||
|
<div className="min-h-[25vh] justify-center flex items-center gap-4">
|
||||||
|
<Loader2
|
||||||
|
className="animate-spin text-muted-foreground"
|
||||||
|
size={32}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-4 text-sm text-muted-foreground">
|
||||||
|
<p className="text-primary text-base font-semibold">
|
||||||
|
Choose how to add SSH Keys to your server:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Radio button options */}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<RadioGroup
|
||||||
|
value={selectedOption}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSelectedOption(value as "manual" | "provider");
|
||||||
|
}}
|
||||||
|
className="grid gap-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="manual" id="manual" />
|
||||||
|
<Label
|
||||||
|
htmlFor="manual"
|
||||||
|
className="text-primary font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
Add SSH Key to Server Manually
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="provider" id="provider" />
|
||||||
|
<Label
|
||||||
|
htmlFor="provider"
|
||||||
|
className="text-primary font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
Add SSH Key when creating server in your provider
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content based on selected option */}
|
||||||
|
{selectedOption === "manual" && (
|
||||||
|
<div className="flex flex-col gap-2 w-full border rounded-lg p-4">
|
||||||
|
<span className="text-base font-semibold text-primary">
|
||||||
|
Manual Setup Instructions
|
||||||
|
</span>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li className="items-center flex gap-1">
|
||||||
|
1. Login to your server
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
2. When you are logged in run the following command
|
||||||
|
<div className="flex relative flex-col gap-4 w-full mt-2">
|
||||||
|
<CodeEditor
|
||||||
|
lineWrapping
|
||||||
|
language="properties"
|
||||||
|
value={`echo "${cloudSSHKey?.publicKey}" >> ~/.ssh/authorized_keys`}
|
||||||
|
readOnly
|
||||||
|
className="font-mono opacity-60"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-2 top-2"
|
||||||
|
onClick={() => {
|
||||||
|
copy(
|
||||||
|
`echo "${cloudSSHKey?.publicKey}" >> ~/.ssh/authorized_keys`,
|
||||||
|
);
|
||||||
|
toast.success("Copied to clipboard");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CopyIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li className="mt-1">
|
||||||
|
3. You're done, follow the next step to insert the
|
||||||
|
details of your server.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedOption === "provider" && (
|
||||||
|
<div className="flex flex-col gap-2 w-full border rounded-lg p-4">
|
||||||
|
<span className="text-base font-semibold text-primary">
|
||||||
|
Provider Setup Instructions
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-col gap-4 w-full overflow-auto">
|
||||||
|
<div className="flex relative flex-col gap-2 overflow-y-auto">
|
||||||
|
<div className="text-sm text-primary flex flex-row gap-2 items-center">
|
||||||
|
Copy Public Key
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="right-2 top-8"
|
||||||
|
onClick={() => {
|
||||||
|
copy(
|
||||||
|
cloudSSHKey?.publicKey || "Generate a SSH Key",
|
||||||
|
);
|
||||||
|
toast.success("SSH Copied to clipboard");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CopyIcon className="size-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm mt-2">
|
||||||
|
Use this public key when creating a server in your
|
||||||
|
preferred provider (Hostinger, Digital Ocean, Hetzner,
|
||||||
|
etc.)
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="https://docs.dokploy.com/docs/core/remote-servers/instructions#requirements"
|
||||||
|
target="_blank"
|
||||||
|
className="text-primary flex flex-row gap-2 mt-2"
|
||||||
|
>
|
||||||
|
View Tutorial <ExternalLinkIcon className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
type LogLine,
|
||||||
|
parseLogs,
|
||||||
|
} from "@/components/dashboard/docker/logs/utils";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { EditScript } from "../edit-script";
|
||||||
|
|
||||||
|
export const Setup = () => {
|
||||||
|
const { data: servers } = api.server.all.useQuery();
|
||||||
|
const [serverId, setServerId] = useState<string>(
|
||||||
|
servers?.[0]?.serverId || "",
|
||||||
|
);
|
||||||
|
const { data: server } = api.server.one.useQuery(
|
||||||
|
{
|
||||||
|
serverId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!serverId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
|
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||||
|
const [isDeploying, setIsDeploying] = useState(false);
|
||||||
|
api.server.setupWithLogs.useSubscription(
|
||||||
|
{
|
||||||
|
serverId: serverId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: isDeploying,
|
||||||
|
onData(log) {
|
||||||
|
if (!isDrawerOpen) {
|
||||||
|
setIsDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log === "Deployment completed successfully!") {
|
||||||
|
setIsDeploying(false);
|
||||||
|
}
|
||||||
|
const parsedLogs = parseLogs(log);
|
||||||
|
setFilteredLogs((prev) => [...prev, ...parsedLogs]);
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
console.error("Deployment logs error:", error);
|
||||||
|
setIsDeploying(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Card className="bg-background">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<Label>Select the server and click on setup server</Label>
|
||||||
|
<Select onValueChange={setServerId} defaultValue={serverId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a server" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{servers?.map((server) => (
|
||||||
|
<SelectItem key={server.serverId} value={server.serverId}>
|
||||||
|
{server.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<CardTitle className="text-xl">Setup Server</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
To setup a server, please click on the button below.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4 min-h-[25vh] items-center">
|
||||||
|
<div className="flex flex-col gap-4 items-center h-full max-w-xl mx-auto min-h-[25vh] justify-center">
|
||||||
|
<span className="text-sm text-muted-foreground text-center">
|
||||||
|
When your server is ready, you can click on the button below, to
|
||||||
|
directly run the script we use for setup the server or directly
|
||||||
|
modify the script
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
<EditScript serverId={server?.serverId || ""} />
|
||||||
|
<DialogAction
|
||||||
|
title={"Setup Server?"}
|
||||||
|
type="default"
|
||||||
|
description="This will setup the server and all associated data"
|
||||||
|
onClick={async () => {
|
||||||
|
setIsDeploying(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button>Setup Server</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DrawerLogs
|
||||||
|
isOpen={isDrawerOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsDrawerOpen(false);
|
||||||
|
setFilteredLogs([]);
|
||||||
|
setIsDeploying(false);
|
||||||
|
}}
|
||||||
|
filteredLogs={filteredLogs}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { Loader2, PcCase, RefreshCw } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { StatusRow } from "../gpu-support";
|
||||||
|
|
||||||
|
export const Verify = () => {
|
||||||
|
const { data: servers } = api.server.all.useQuery();
|
||||||
|
const [serverId, setServerId] = useState<string>(
|
||||||
|
servers?.[0]?.serverId || "",
|
||||||
|
);
|
||||||
|
const { data, refetch, error, isPending, isError } =
|
||||||
|
api.server.validate.useQuery(
|
||||||
|
{ serverId },
|
||||||
|
{
|
||||||
|
enabled: !!serverId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Card className="bg-background">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
<Label>Select a server</Label>
|
||||||
|
<Select onValueChange={setServerId} defaultValue={serverId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a server" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{servers?.map((server) => (
|
||||||
|
<SelectItem key={server.serverId} value={server.serverId}>
|
||||||
|
{server.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PcCase className="size-5" />
|
||||||
|
<CardTitle className="text-xl">Setup Validation</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
Check if your server is ready for deployment
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
isLoading={isRefreshing}
|
||||||
|
onClick={async () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
await refetch();
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
{isError && (
|
||||||
|
<AlertBlock type="error" className="w-full">
|
||||||
|
{error.message}
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex flex-col gap-4 min-h-[25vh]">
|
||||||
|
{isPending ? (
|
||||||
|
<div className="flex items-center justify-center text-muted-foreground py-4">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
<span>Checking Server configuration</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid w-full gap-4">
|
||||||
|
<div className="border rounded-lg p-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-1">Status</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Shows the server configuration status
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-2.5">
|
||||||
|
<StatusRow
|
||||||
|
label="Docker Installed"
|
||||||
|
isEnabled={data?.docker?.enabled}
|
||||||
|
description={
|
||||||
|
data?.docker?.enabled
|
||||||
|
? `Installed: ${data?.docker?.version}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatusRow
|
||||||
|
label="RClone Installed"
|
||||||
|
isEnabled={data?.rclone?.enabled}
|
||||||
|
description={
|
||||||
|
data?.rclone?.enabled
|
||||||
|
? `Installed: ${data?.rclone?.version}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatusRow
|
||||||
|
label="Nixpacks Installed"
|
||||||
|
isEnabled={data?.nixpacks?.enabled}
|
||||||
|
description={
|
||||||
|
data?.nixpacks?.enabled
|
||||||
|
? `Installed: ${data?.nixpacks?.version}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatusRow
|
||||||
|
label="Buildpacks Installed"
|
||||||
|
isEnabled={data?.buildpacks?.enabled}
|
||||||
|
description={
|
||||||
|
data?.buildpacks?.enabled
|
||||||
|
? `Installed: ${data?.buildpacks?.version}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatusRow
|
||||||
|
label="Docker Swarm Initialized"
|
||||||
|
isEnabled={data?.isSwarmInstalled}
|
||||||
|
description={
|
||||||
|
data?.isSwarmInstalled
|
||||||
|
? "Initialized"
|
||||||
|
: "Not Initialized"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatusRow
|
||||||
|
label="Dokploy Network Created"
|
||||||
|
isEnabled={data?.isDokployNetworkInstalled}
|
||||||
|
description={
|
||||||
|
data?.isDokployNetworkInstalled
|
||||||
|
? "Created"
|
||||||
|
: "Not Created"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatusRow
|
||||||
|
label="Main Directory Created"
|
||||||
|
isEnabled={data?.isMainDirectoryInstalled}
|
||||||
|
description={
|
||||||
|
data?.isMainDirectoryInstalled
|
||||||
|
? "Created"
|
||||||
|
: "Not Created"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
);
|
||||||
|
};
|
||||||
+415
-235
@@ -1,261 +1,441 @@
|
|||||||
import { defineStepper } from "@stepperize/react";
|
import { defineStepper } from "@stepperize/react";
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
BookIcon,
|
||||||
DatabaseIcon,
|
Code2,
|
||||||
GitMergeIcon,
|
Database,
|
||||||
GlobeIcon,
|
GitMerge,
|
||||||
UsersIcon,
|
Globe,
|
||||||
|
Plug,
|
||||||
|
Puzzle,
|
||||||
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Fragment, useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import ConfettiExplosion from "react-confetti-explosion";
|
import ConfettiExplosion from "react-confetti-explosion";
|
||||||
import { DeployStep } from "@/components/dashboard/onboarding/steps/deploy-step";
|
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { ProjectStep } from "@/components/dashboard/onboarding/steps/project-step";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { ServerStep } from "@/components/dashboard/onboarding/steps/server-step";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogFooter } from "@/components/ui/dialog";
|
import {
|
||||||
import { cn } from "@/lib/utils";
|
Dialog,
|
||||||
import { api } from "@/utils/api";
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { CreateServer } from "./create-server";
|
||||||
|
import { CreateSSHKey } from "./create-ssh-key";
|
||||||
|
import { Setup } from "./setup";
|
||||||
|
import { Verify } from "./verify";
|
||||||
|
|
||||||
const { useStepper, steps, Scoped } = defineStepper(
|
export const { useStepper, steps, Scoped } = defineStepper(
|
||||||
{ id: "welcome", title: "Welcome" },
|
{
|
||||||
{ id: "project", title: "New project" },
|
id: "requisites",
|
||||||
{ id: "server", title: "Connect server" },
|
title: "Requisites",
|
||||||
{ id: "deploy", title: "Ship something" },
|
description: "Check your requisites",
|
||||||
{ id: "complete", title: "You're live" },
|
},
|
||||||
|
{
|
||||||
|
id: "create-ssh-key",
|
||||||
|
title: "SSH Key",
|
||||||
|
description: "Create your ssh key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "connect-server",
|
||||||
|
title: "Connect",
|
||||||
|
description: "Connect",
|
||||||
|
},
|
||||||
|
{ id: "setup", title: "Setup", description: "Setup your server" },
|
||||||
|
{ id: "verify", title: "Verify", description: "Verify your server" },
|
||||||
|
{ id: "complete", title: "Complete", description: "Checkout complete" },
|
||||||
);
|
);
|
||||||
|
|
||||||
const features = [
|
|
||||||
{
|
|
||||||
icon: DatabaseIcon,
|
|
||||||
title: "Databases",
|
|
||||||
description: "Postgres, MySQL, MongoDB, Redis and more, one click away.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: GlobeIcon,
|
|
||||||
title: "Custom domains",
|
|
||||||
description: "Attach your own domains and get automatic HTTPS.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: GitMergeIcon,
|
|
||||||
title: "CI/CD",
|
|
||||||
description: "Auto-deploy on every push from GitHub, GitLab or Bitbucket.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: UsersIcon,
|
|
||||||
title: "Team collaboration",
|
|
||||||
description: "Invite teammates with fine-grained permissions.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const WelcomeIntro = ({ onNext }: { onNext: () => void }) => (
|
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
|
||||||
Welcome
|
|
||||||
</span>
|
|
||||||
<h1 className="text-xl font-semibold tracking-tight">
|
|
||||||
Welcome to Dokploy Cloud
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground leading-relaxed">
|
|
||||||
Thanks for subscribing — you're all set up. Next, connect a server so
|
|
||||||
you can start deploying.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button size="lg" onClick={onNext} className="w-fit px-8">
|
|
||||||
Get started
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const CompleteIntro = ({ onFinish }: { onFinish: () => void }) => {
|
|
||||||
const [showConfetti, setShowConfetti] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
setShowConfetti(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
<div className="fixed inset-x-0 top-0 flex justify-center pointer-events-none">
|
|
||||||
{showConfetti && (
|
|
||||||
<ConfettiExplosion
|
|
||||||
duration={3000}
|
|
||||||
force={0.35}
|
|
||||||
particleCount={200}
|
|
||||||
width={1200}
|
|
||||||
zIndex={60}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
|
||||||
Done
|
|
||||||
</span>
|
|
||||||
<h1 className="text-xl font-semibold tracking-tight">
|
|
||||||
You're all set.
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground leading-relaxed">
|
|
||||||
Your server is connected — here's some of what you can do next.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl className="flex flex-col divide-y">
|
|
||||||
{features.map((feature, index) => (
|
|
||||||
<div key={feature.title} className="flex gap-6 py-4 first:pt-0">
|
|
||||||
<dt className="font-mono text-xs text-muted-foreground pt-1 shrink-0 w-6">
|
|
||||||
{String(index + 1).padStart(2, "0")}
|
|
||||||
</dt>
|
|
||||||
<dd className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium flex items-center gap-2">
|
|
||||||
<feature.icon className="size-4 text-muted-foreground" />
|
|
||||||
{feature.title}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{feature.description}
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button size="lg" className="w-fit px-8" onClick={onFinish}>
|
|
||||||
Go to dashboard
|
|
||||||
</Button>
|
|
||||||
<Link
|
|
||||||
href="https://discord.com/invite/2tBnJ3jDJc"
|
|
||||||
target="_blank"
|
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
Need help? Join our Discord →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WelcomeSubscription = () => {
|
export const WelcomeSubscription = () => {
|
||||||
const router = useRouter();
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
const stepper = useStepper();
|
const stepper = useStepper();
|
||||||
const [isOpen, setIsOpen] = useState(true);
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
|
const router = useRouter();
|
||||||
|
const { push } = router;
|
||||||
|
|
||||||
// This flow can also fire for an existing customer upgrading their plan
|
useEffect(() => {
|
||||||
// (any checkout success redirects here), not just a first-time
|
const confettiShown = localStorage.getItem("hasShownConfetti");
|
||||||
// subscriber — so "New project" only belongs in the flow when they don't
|
if (!confettiShown) {
|
||||||
// already have one. Mirrors the isCloud-based step filtering in the main
|
setShowConfetti(true);
|
||||||
// onboarding wizard.
|
localStorage.setItem("hasShownConfetti", "true");
|
||||||
const { data: projects } = api.project.all.useQuery();
|
}
|
||||||
const hasExistingProject = (projects?.length ?? 0) > 0;
|
}, [showConfetti]);
|
||||||
const visibleStepIds = hasExistingProject
|
|
||||||
? steps.filter((step) => step.id !== "project").map((step) => step.id)
|
|
||||||
: steps.map((step) => step.id);
|
|
||||||
const visibleIndex = visibleStepIds.indexOf(stepper.current.id);
|
|
||||||
const isLast = visibleIndex === visibleStepIds.length - 1;
|
|
||||||
const goToNextVisible = () => {
|
|
||||||
const nextId = visibleStepIds[visibleIndex + 1];
|
|
||||||
if (nextId) stepper.goTo(nextId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [projectId, setProjectId] = useState<string | undefined>();
|
|
||||||
const [environmentId, setEnvironmentId] = useState<string | undefined>();
|
|
||||||
|
|
||||||
const firstExistingProjectId = projects?.[0]?.projectId;
|
|
||||||
const { data: existingEnvironments } = api.environment.byProjectId.useQuery(
|
|
||||||
{ projectId: firstExistingProjectId ?? "" },
|
|
||||||
{ enabled: !!firstExistingProjectId && !environmentId },
|
|
||||||
);
|
|
||||||
const resolvedEnvironmentId =
|
|
||||||
environmentId ?? existingEnvironments?.[0]?.environmentId;
|
|
||||||
|
|
||||||
const close = (destination: string) => {
|
|
||||||
setIsOpen(false);
|
|
||||||
router.push(destination);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) close("/dashboard/settings/servers");
|
setIsOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
const { success, ...rest } = router.query;
|
||||||
|
router.replace(
|
||||||
|
{ pathname: router.pathname, query: rest },
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
shallow: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-2xl">
|
<DialogContent className="sm:max-w-7xl min-h-[75vh]">
|
||||||
<Scoped>
|
{showConfetti ?? "Flaso"}
|
||||||
<nav aria-label="Steps" className="flex items-center gap-2 pt-2">
|
<div className="flex justify-center items-center w-full">
|
||||||
{visibleStepIds.map((id, index) => {
|
{showConfetti && (
|
||||||
const isDone = index < visibleIndex;
|
<ConfettiExplosion
|
||||||
const isCurrent = stepper.current.id === id;
|
duration={3000}
|
||||||
return (
|
force={0.3}
|
||||||
<Fragment key={id}>
|
particleSize={12}
|
||||||
<button
|
particleCount={300}
|
||||||
type="button"
|
className="z-9999"
|
||||||
disabled={!isDone}
|
zIndex={9999}
|
||||||
onClick={() => stepper.goTo(id)}
|
width={1500}
|
||||||
aria-current={isCurrent ? "step" : undefined}
|
/>
|
||||||
className={cn(
|
)}
|
||||||
"flex size-7 shrink-0 items-center justify-center rounded-full font-mono text-[11px] transition-colors",
|
|
||||||
isCurrent
|
|
||||||
? "border-2 border-primary text-primary font-semibold"
|
|
||||||
: isDone
|
|
||||||
? "bg-primary text-primary-foreground cursor-pointer"
|
|
||||||
: "border border-border text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isDone ? <CheckIcon className="size-3.5" /> : index + 1}
|
|
||||||
</button>
|
|
||||||
{index < visibleStepIds.length - 1 && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"h-px flex-1",
|
|
||||||
isDone ? "bg-primary" : "bg-border",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</Scoped>
|
|
||||||
|
|
||||||
<div className="py-2">
|
|
||||||
{stepper.switch({
|
|
||||||
welcome: () => <WelcomeIntro onNext={goToNextVisible} />,
|
|
||||||
project: () => (
|
|
||||||
<ProjectStep
|
|
||||||
plainTitle
|
|
||||||
onNext={(project) => {
|
|
||||||
setProjectId(project.projectId);
|
|
||||||
setEnvironmentId(project.environmentId);
|
|
||||||
goToNextVisible();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
server: () => <ServerStep plainTitle onNext={goToNextVisible} />,
|
|
||||||
deploy: () => (
|
|
||||||
<DeployStep
|
|
||||||
plainTitle
|
|
||||||
environmentId={resolvedEnvironmentId}
|
|
||||||
onNext={goToNextVisible}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
complete: () => (
|
|
||||||
<CompleteIntro onFinish={() => close("/dashboard/home")} />
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isLast && (
|
<DialogHeader>
|
||||||
<DialogFooter>
|
<DialogTitle className="text-2xl text-center">
|
||||||
<Button
|
Welcome To Dokploy Cloud 🎉
|
||||||
variant="ghost"
|
</DialogTitle>
|
||||||
onClick={() => close("/dashboard/settings/servers")}
|
<DialogDescription className="text-center max-w-xl mx-auto">
|
||||||
>
|
Thank you for choosing Dokploy Cloud! 🚀 We're excited to have you
|
||||||
Skip for now
|
onboard. Before you dive in, you'll need to configure your remote
|
||||||
</Button>
|
server to unlock all the features we offer.
|
||||||
</DialogFooter>
|
</DialogDescription>
|
||||||
)}
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Steps</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Step {stepper.current.index + 1} of {steps.length}
|
||||||
|
</span>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Scoped>
|
||||||
|
<nav aria-label="Checkout Steps" className="group my-4">
|
||||||
|
<ol
|
||||||
|
className="flex items-center justify-between gap-2"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
>
|
||||||
|
{stepper.all.map((step, index, array) => (
|
||||||
|
<React.Fragment key={step.id}>
|
||||||
|
<li className="flex items-center gap-4 shrink-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
variant={
|
||||||
|
index <= stepper.current.index ? "secondary" : "ghost"
|
||||||
|
}
|
||||||
|
aria-current={
|
||||||
|
stepper.current.id === step.id ? "step" : undefined
|
||||||
|
}
|
||||||
|
aria-posinset={index + 1}
|
||||||
|
aria-setsize={steps.length}
|
||||||
|
aria-selected={stepper.current.id === step.id}
|
||||||
|
className="flex size-10 items-center justify-center rounded-full border-2 border-border"
|
||||||
|
onClick={() => stepper.goTo(step.id)}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm font-medium">{step.title}</span>
|
||||||
|
</li>
|
||||||
|
{index < array.length - 1 && (
|
||||||
|
<Separator
|
||||||
|
className={`flex-1 ${
|
||||||
|
index < stepper.current.index
|
||||||
|
? "bg-primary"
|
||||||
|
: "bg-muted"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
{stepper.switch({
|
||||||
|
requisites: () => (
|
||||||
|
<div className="flex flex-col gap-2 border p-4 rounded-lg">
|
||||||
|
<span className="text-primary text-base font-bold">
|
||||||
|
Before getting started, please follow the steps below to
|
||||||
|
ensure the best experience:
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-primary text-sm font-medium">
|
||||||
|
Supported Distributions:
|
||||||
|
</p>
|
||||||
|
<ul className="list-inside list-disc pl-4 text-sm text-muted-foreground mt-4">
|
||||||
|
<li>Ubuntu 24.04 LTS</li>
|
||||||
|
<li>Ubuntu 23.10</li>
|
||||||
|
<li>Ubuntu 22.04 LTS</li>
|
||||||
|
<li>Ubuntu 20.04 LTS</li>
|
||||||
|
<li>Ubuntu 18.04 LTS</li>
|
||||||
|
<li>Debian 12</li>
|
||||||
|
<li>Debian 11</li>
|
||||||
|
<li>Debian 10</li>
|
||||||
|
<li>Fedora 40</li>
|
||||||
|
<li>CentOS 9</li>
|
||||||
|
<li>CentOS 8</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-primary text-sm font-medium">
|
||||||
|
You will need to purchase or rent a Virtual Private Server
|
||||||
|
(VPS) to proceed, we recommend to use one of these
|
||||||
|
providers since has been heavily tested.
|
||||||
|
</p>
|
||||||
|
<ul className="list-inside list-disc pl-4 text-sm text-muted-foreground mt-4">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://www.hostinger.com/vps-hosting?REFERRALCODE=1SIUMAURICI97"
|
||||||
|
className="text-link underline"
|
||||||
|
>
|
||||||
|
Hostinger - Get 20% Discount
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href=" https://app.americancloud.com/register?ref=dokploy"
|
||||||
|
className="text-link underline"
|
||||||
|
>
|
||||||
|
American Cloud - Get $20 Credits
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://m.do.co/c/db24efd43f35"
|
||||||
|
className="text-link underline"
|
||||||
|
>
|
||||||
|
DigitalOcean - Get $200 Credits
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://hetzner.cloud/?ref=vou4fhxJ1W2D"
|
||||||
|
className="text-link underline"
|
||||||
|
>
|
||||||
|
Hetzner - Get €20 Credits
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://www.vultr.com/?ref=9679828"
|
||||||
|
className="text-link underline"
|
||||||
|
>
|
||||||
|
Vultr
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://www.linode.com/es/pricing/#compute-shared"
|
||||||
|
className="text-link underline"
|
||||||
|
>
|
||||||
|
Linode
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<AlertBlock className="mt-4 px-4">
|
||||||
|
You are free to use whatever provider, but we recommend to
|
||||||
|
use one of the above, to avoid issues.
|
||||||
|
</AlertBlock>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
"create-ssh-key": () => <CreateSSHKey />,
|
||||||
|
"connect-server": () => <CreateServer stepper={stepper} />,
|
||||||
|
setup: () => <Setup />,
|
||||||
|
verify: () => <Verify />,
|
||||||
|
complete: () => {
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
title: "Scalable Deployments",
|
||||||
|
description:
|
||||||
|
"Deploy and scale your applications effortlessly to handle any workload.",
|
||||||
|
icon: <Database className="text-primary" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Automated Backups",
|
||||||
|
description: "Protect your data with automatic backups",
|
||||||
|
icon: <Database className="text-primary" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Open Source Templates",
|
||||||
|
description:
|
||||||
|
"Big list of common open source templates in one-click",
|
||||||
|
icon: <Puzzle className="text-primary" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Custom Domains",
|
||||||
|
description:
|
||||||
|
"Link your own domains to your applications for a professional presence.",
|
||||||
|
icon: <Globe className="text-primary" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "CI/CD Integration",
|
||||||
|
description:
|
||||||
|
"Implement continuous integration and deployment workflows to streamline development.",
|
||||||
|
icon: <GitMerge className="text-primary" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Database Management",
|
||||||
|
description:
|
||||||
|
"Efficiently manage your databases with intuitive tools.",
|
||||||
|
icon: <Database className="text-primary" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Team Collaboration",
|
||||||
|
description:
|
||||||
|
"Collaborate with your team on shared projects with customizable permissions.",
|
||||||
|
icon: <Users className="text-primary" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Multi-language Support",
|
||||||
|
description:
|
||||||
|
"Deploy applications in multiple programming languages to suit your needs.",
|
||||||
|
icon: <Code2 className="text-primary" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "API Access",
|
||||||
|
description:
|
||||||
|
"Integrate and manage your applications via robust and well-documented APIs.",
|
||||||
|
icon: <Plug className="text-primary" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h2 className="text-lg font-semibold">You're All Set!</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Did you know you can deploy any number of applications
|
||||||
|
that your server can handle?
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Here are some of the things you can do with Dokploy
|
||||||
|
Cloud:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
||||||
|
{features.map((feature) => (
|
||||||
|
<div
|
||||||
|
key={feature.title}
|
||||||
|
className="flex flex-col items-start p-4 bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="text-3xl mb-2">{feature.icon}</div>
|
||||||
|
<h3 className="text-lg font-medium mb-1">
|
||||||
|
{feature.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 mt-4">
|
||||||
|
<span className="text-base text-primary">
|
||||||
|
Need Help? We are here to help you.
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Join to our Discord server and we will help you.
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-row gap-4">
|
||||||
|
<Button className="rounded-full bg-[#5965F2] hover:bg-[#4A55E0] w-fit">
|
||||||
|
<Link
|
||||||
|
href="https://discord.gg/2tBnJ3jDJc"
|
||||||
|
aria-label="Dokploy on GitHub"
|
||||||
|
target="_blank"
|
||||||
|
className="flex flex-row items-center gap-2 text-white"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
role="img"
|
||||||
|
className="h-6 w-6 fill-white"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||||
|
</svg>
|
||||||
|
Join Discord
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button className="rounded-full w-fit">
|
||||||
|
<Link
|
||||||
|
href="https://github.com/Dokploy/dokploy"
|
||||||
|
aria-label="Dokploy on GitHub"
|
||||||
|
target="_blank"
|
||||||
|
className="flex flex-row items-center gap-2 "
|
||||||
|
>
|
||||||
|
<GithubIcon />
|
||||||
|
Github
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="rounded-full w-fit"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="https://docs.dokploy.com/docs/core"
|
||||||
|
aria-label="Dokploy Docs"
|
||||||
|
target="_blank"
|
||||||
|
className="flex flex-row items-center gap-2 "
|
||||||
|
>
|
||||||
|
<BookIcon size={16} />
|
||||||
|
Docs
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</Scoped>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
{!stepper.isLast && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
push("/dashboard/settings/servers");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 w-full justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={stepper.prev}
|
||||||
|
disabled={stepper.isFirst}
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (stepper.isLast) {
|
||||||
|
setIsOpen(false);
|
||||||
|
push("/dashboard/home");
|
||||||
|
} else {
|
||||||
|
stepper.next();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stepper.isLast ? "Complete" : "Next"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -124,11 +124,8 @@ export const AddInvitation = () => {
|
|||||||
const mode = form.watch("mode");
|
const mode = form.watch("mode");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
form.reset();
|
||||||
form.reset();
|
}, [form, form.formState.isSubmitSuccessful, form.reset]);
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}, [open, form, form.reset]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCloud && form.getValues("mode") === "credentials") {
|
if (isCloud && form.getValues("mode") === "credentials") {
|
||||||
@@ -228,7 +225,7 @@ export const AddInvitation = () => {
|
|||||||
<FormLabel>Invite Method</FormLabel>
|
<FormLabel>Invite Method</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
value={field.value}
|
defaultValue={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -316,7 +313,7 @@ export const AddInvitation = () => {
|
|||||||
<FormLabel>Email Provider</FormLabel>
|
<FormLabel>Email Provider</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
value={field.value}
|
defaultValue={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ const providerLabels = {
|
|||||||
hashicorp: "HashiCorp Vault / OpenBao",
|
hashicorp: "HashiCorp Vault / OpenBao",
|
||||||
infisical: "Infisical",
|
infisical: "Infisical",
|
||||||
aws: "AWS Secrets Manager",
|
aws: "AWS Secrets Manager",
|
||||||
"aws-parameter-store": "AWS Parameter Store",
|
|
||||||
doppler: "Doppler",
|
doppler: "Doppler",
|
||||||
azure: "Azure Key Vault",
|
azure: "Azure Key Vault",
|
||||||
scaleway: "Scaleway Secret Manager",
|
scaleway: "Scaleway Secret Manager",
|
||||||
@@ -62,7 +61,6 @@ const VaultProviderSchema = z
|
|||||||
"hashicorp",
|
"hashicorp",
|
||||||
"infisical",
|
"infisical",
|
||||||
"aws",
|
"aws",
|
||||||
"aws-parameter-store",
|
|
||||||
"doppler",
|
"doppler",
|
||||||
"azure",
|
"azure",
|
||||||
"scaleway",
|
"scaleway",
|
||||||
@@ -81,7 +79,6 @@ const VaultProviderSchema = z
|
|||||||
region: z.string(),
|
region: z.string(),
|
||||||
accessKeyId: z.string(),
|
accessKeyId: z.string(),
|
||||||
secretAccessKey: z.string(),
|
secretAccessKey: z.string(),
|
||||||
parameterPath: z.string(),
|
|
||||||
serviceToken: z.string(),
|
serviceToken: z.string(),
|
||||||
awsEndpoint: z.string(),
|
awsEndpoint: z.string(),
|
||||||
vaultUri: z.string(),
|
vaultUri: z.string(),
|
||||||
@@ -139,8 +136,7 @@ const VaultProviderSchema = z
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
(data.providerType === "aws" ||
|
data.providerType === "aws" &&
|
||||||
data.providerType === "aws-parameter-store") &&
|
|
||||||
data.awsEndpoint &&
|
data.awsEndpoint &&
|
||||||
!isValidUrl(data.awsEndpoint)
|
!isValidUrl(data.awsEndpoint)
|
||||||
) {
|
) {
|
||||||
@@ -150,17 +146,6 @@ const VaultProviderSchema = z
|
|||||||
path: ["awsEndpoint"],
|
path: ["awsEndpoint"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
data.providerType === "aws-parameter-store" &&
|
|
||||||
data.parameterPath &&
|
|
||||||
!data.parameterPath.trim().startsWith("/")
|
|
||||||
) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: "Parameter discovery path must start with /",
|
|
||||||
path: ["parameterPath"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
data.providerType === "scaleway" &&
|
data.providerType === "scaleway" &&
|
||||||
data.scalewayApiUrl &&
|
data.scalewayApiUrl &&
|
||||||
@@ -215,11 +200,6 @@ const VaultProviderSchema = z
|
|||||||
["accessKeyId", "Access Key ID is required"],
|
["accessKeyId", "Access Key ID is required"],
|
||||||
["secretAccessKey", "Secret Access Key is required"],
|
["secretAccessKey", "Secret Access Key is required"],
|
||||||
],
|
],
|
||||||
"aws-parameter-store": [
|
|
||||||
["region", "Region is required"],
|
|
||||||
["accessKeyId", "Access Key ID is required"],
|
|
||||||
["secretAccessKey", "Secret Access Key is required"],
|
|
||||||
],
|
|
||||||
doppler: [["serviceToken", "Service Token is required"]],
|
doppler: [["serviceToken", "Service Token is required"]],
|
||||||
azure: [
|
azure: [
|
||||||
["vaultUri", "Vault URI is required"],
|
["vaultUri", "Vault URI is required"],
|
||||||
@@ -285,7 +265,6 @@ const defaultValues: VaultProviderForm = {
|
|||||||
region: "",
|
region: "",
|
||||||
accessKeyId: "",
|
accessKeyId: "",
|
||||||
secretAccessKey: "",
|
secretAccessKey: "",
|
||||||
parameterPath: "",
|
|
||||||
serviceToken: "",
|
serviceToken: "",
|
||||||
awsEndpoint: "",
|
awsEndpoint: "",
|
||||||
vaultUri: "",
|
vaultUri: "",
|
||||||
@@ -334,15 +313,6 @@ const buildConfig = (data: VaultProviderForm) => {
|
|||||||
secretAccessKey: data.secretAccessKey,
|
secretAccessKey: data.secretAccessKey,
|
||||||
endpoint: data.awsEndpoint || undefined,
|
endpoint: data.awsEndpoint || undefined,
|
||||||
};
|
};
|
||||||
case "aws-parameter-store":
|
|
||||||
return {
|
|
||||||
providerType: "aws-parameter-store" as const,
|
|
||||||
region: data.region,
|
|
||||||
accessKeyId: data.accessKeyId,
|
|
||||||
secretAccessKey: data.secretAccessKey,
|
|
||||||
endpoint: data.awsEndpoint || undefined,
|
|
||||||
parameterPath: data.parameterPath.trim() || undefined,
|
|
||||||
};
|
|
||||||
case "doppler":
|
case "doppler":
|
||||||
return {
|
return {
|
||||||
providerType: "doppler" as const,
|
providerType: "doppler" as const,
|
||||||
@@ -473,16 +443,12 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
|||||||
environmentSlug: provider.config.environmentSlug,
|
environmentSlug: provider.config.environmentSlug,
|
||||||
secretPath: provider.config.secretPath,
|
secretPath: provider.config.secretPath,
|
||||||
}),
|
}),
|
||||||
...((provider.config.providerType === "aws" ||
|
...(provider.config.providerType === "aws" && {
|
||||||
provider.config.providerType === "aws-parameter-store") && {
|
|
||||||
region: provider.config.region,
|
region: provider.config.region,
|
||||||
accessKeyId: provider.config.accessKeyId,
|
accessKeyId: provider.config.accessKeyId,
|
||||||
secretAccessKey: provider.config.secretAccessKey,
|
secretAccessKey: provider.config.secretAccessKey,
|
||||||
awsEndpoint: provider.config.endpoint ?? "",
|
awsEndpoint: provider.config.endpoint ?? "",
|
||||||
}),
|
}),
|
||||||
...(provider.config.providerType === "aws-parameter-store" && {
|
|
||||||
parameterPath: provider.config.parameterPath ?? "",
|
|
||||||
}),
|
|
||||||
...(provider.config.providerType === "doppler" && {
|
...(provider.config.providerType === "doppler" && {
|
||||||
serviceToken: provider.config.serviceToken,
|
serviceToken: provider.config.serviceToken,
|
||||||
dopplerProject: provider.config.project ?? "",
|
dopplerProject: provider.config.project ?? "",
|
||||||
@@ -791,8 +757,7 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(providerType === "aws" ||
|
{providerType === "aws" && (
|
||||||
providerType === "aws-parameter-store") && (
|
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -850,44 +815,12 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{providerType === "aws-parameter-store" && (
|
<FormDescription>
|
||||||
<FormField
|
Reference format:{" "}
|
||||||
control={form.control}
|
<code>{"${{vault.<name>.secret-name}}"}</code> or{" "}
|
||||||
name="parameterPath"
|
<code>{"${{vault.<name>.secret-name:field}}"}</code> for JSON
|
||||||
render={({ field }) => (
|
secrets
|
||||||
<FormItem>
|
</FormDescription>
|
||||||
<FormLabel>Discovery path (optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="/production/my-app" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Limits parameter browsing and autocomplete to this
|
|
||||||
hierarchy. IAM permissions remain the security
|
|
||||||
boundary.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{providerType === "aws" ? (
|
|
||||||
<FormDescription>
|
|
||||||
Reference format:{" "}
|
|
||||||
<code>{"${{vault.<name>.secret-name}}"}</code> or{" "}
|
|
||||||
<code>{"${{vault.<name>.secret-name:field}}"}</code> for
|
|
||||||
JSON secrets
|
|
||||||
</FormDescription>
|
|
||||||
) : (
|
|
||||||
<FormDescription>
|
|
||||||
Reference format:{" "}
|
|
||||||
<code>{"${{vault.<name>./path/to/parameter}}"}</code>.
|
|
||||||
SecureString parameters are decrypted automatically.
|
|
||||||
Resolution requires <code>ssm:GetParameters</code>.
|
|
||||||
Discovery requires <code>ssm:DescribeParameters</code>.
|
|
||||||
Customer-managed KMS keys also require{" "}
|
|
||||||
<code>kms:Decrypt</code>.
|
|
||||||
</FormDescription>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ const providerLabels: Record<string, string> = {
|
|||||||
hashicorp: "HashiCorp Vault",
|
hashicorp: "HashiCorp Vault",
|
||||||
infisical: "Infisical",
|
infisical: "Infisical",
|
||||||
aws: "AWS Secrets Manager",
|
aws: "AWS Secrets Manager",
|
||||||
"aws-parameter-store": "AWS Parameter Store",
|
|
||||||
doppler: "Doppler",
|
doppler: "Doppler",
|
||||||
azure: "Azure Key Vault",
|
azure: "Azure Key Vault",
|
||||||
scaleway: "Scaleway Secret Manager",
|
scaleway: "Scaleway Secret Manager",
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
|||||||
term.loadAddon(clipboardAddon);
|
term.loadAddon(clipboardAddon);
|
||||||
fixMacOsAltKeys(term);
|
fixMacOsAltKeys(term);
|
||||||
|
|
||||||
// @ts-expect-error
|
// @ts-ignore
|
||||||
term.open(termRef.current);
|
term.open(termRef.current);
|
||||||
|
// @ts-ignore
|
||||||
term.loadAddon(addonFit);
|
term.loadAddon(addonFit);
|
||||||
addonFit.fit();
|
addonFit.fit();
|
||||||
|
|
||||||
@@ -88,9 +89,9 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
|||||||
}, [id, serverId]);
|
}, [id, serverId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 h-full w-full">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="w-full h-full bg-transparent border rounded-lg p-2">
|
<div className="w-full h-full bg-transparent border rounded-lg p-2">
|
||||||
<div id={id} ref={termRef} className="rounded-xl h-full w-full" />
|
<div id={id} ref={termRef} className="rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Stars,
|
Stars,
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -15,7 +14,6 @@ import { toast } from "sonner";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
@@ -128,13 +126,13 @@ export const UpdateServer = ({
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-lg" showCloseButton={false}>
|
<DialogContent className="max-w-lg">
|
||||||
<div className="flex items-center gap-2 mb-8">
|
<div className="flex items-center justify-between mb-8">
|
||||||
<DialogTitle className="text-2xl font-semibold mr-auto">
|
<DialogTitle className="text-2xl font-semibold">
|
||||||
Web Server Update
|
Web Server Update
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
{dokployVersion && (
|
{dokployVersion && (
|
||||||
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 bg-muted">
|
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 mr-2 bg-muted">
|
||||||
<Server className="h-4 w-4 text-muted-foreground" />
|
<Server className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{dokployVersion}{" "}
|
{dokployVersion}{" "}
|
||||||
@@ -143,12 +141,6 @@ export const UpdateServer = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="ghost" size="icon-sm" className="shrink-0">
|
|
||||||
<X />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Initial state */}
|
{/* Initial state */}
|
||||||
|
|||||||
@@ -1,245 +0,0 @@
|
|||||||
import type { ServiceType } from "@dokploy/server/db/schema";
|
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
|
||||||
import { ArrowRightLeft } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { type LogLine, parseLogs } from "../docker/logs/utils";
|
|
||||||
|
|
||||||
const LOCAL_SERVER = "dokploy";
|
|
||||||
|
|
||||||
const transferSchema = z.object({
|
|
||||||
targetServerId: z.string().min(1, { message: "Select a target server" }),
|
|
||||||
removeSourceData: z.boolean(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TransferForm = z.infer<typeof transferSchema>;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: string;
|
|
||||||
type: ServiceType;
|
|
||||||
serverId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TransferService = ({ id, type, serverId }: Props) => {
|
|
||||||
const utils = api.useUtils();
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
|
||||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
|
||||||
const [isTransferring, setIsTransferring] = useState(false);
|
|
||||||
const [logs, setLogs] = useState<LogLine[]>([]);
|
|
||||||
const [request, setRequest] = useState<{
|
|
||||||
targetServerId: string | null;
|
|
||||||
removeSourceData: boolean;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const targets = [
|
|
||||||
...(!isCloud && serverId
|
|
||||||
? [{ serverId: LOCAL_SERVER, name: "Dokploy Server" }]
|
|
||||||
: []),
|
|
||||||
...(servers ?? []).filter((server) => server.serverId !== serverId),
|
|
||||||
];
|
|
||||||
|
|
||||||
const form = useForm<TransferForm>({
|
|
||||||
defaultValues: { targetServerId: "", removeSourceData: false },
|
|
||||||
resolver: zodResolver(transferSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
api.transfer.start.useSubscription(
|
|
||||||
{
|
|
||||||
serviceType: type,
|
|
||||||
serviceId: id,
|
|
||||||
targetServerId: request?.targetServerId ?? null,
|
|
||||||
removeSourceData: request?.removeSourceData ?? false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: isTransferring && request !== null,
|
|
||||||
onData(line) {
|
|
||||||
setLogs((prev) => [...prev, ...parseLogs(line)]);
|
|
||||||
if (line.startsWith("Transfer completed")) {
|
|
||||||
setIsTransferring(false);
|
|
||||||
toast.success("Service transferred successfully");
|
|
||||||
utils.invalidate();
|
|
||||||
} else if (line.startsWith("Transfer failed")) {
|
|
||||||
setIsTransferring(false);
|
|
||||||
toast.error("Transfer failed, check the logs");
|
|
||||||
utils.invalidate();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError(error) {
|
|
||||||
setIsTransferring(false);
|
|
||||||
toast.error(error.message);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const onSubmit = (values: TransferForm) => {
|
|
||||||
setRequest({
|
|
||||||
targetServerId:
|
|
||||||
values.targetServerId === LOCAL_SERVER ? null : values.targetServerId,
|
|
||||||
removeSourceData: values.removeSourceData,
|
|
||||||
});
|
|
||||||
setLogs([]);
|
|
||||||
setIsDrawerOpen(true);
|
|
||||||
setIsTransferring(true);
|
|
||||||
setIsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-blue-500/10"
|
|
||||||
isLoading={isTransferring}
|
|
||||||
>
|
|
||||||
<ArrowRightLeft className="size-4 text-primary group-hover:text-blue-500" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Transfer to another server</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Moves this service with its volumes, bind mounts, file mounts and
|
|
||||||
configuration to the selected server, then deploys it there.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{targets.length === 0 ? (
|
|
||||||
<AlertBlock type="info">
|
|
||||||
There are no other servers available to transfer this service to.
|
|
||||||
</AlertBlock>
|
|
||||||
) : (
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
id="hook-form-transfer-service"
|
|
||||||
className="grid w-full gap-4"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="targetServerId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Target server</FormLabel>
|
|
||||||
<Select
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a server" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
{targets.map((server) => (
|
|
||||||
<SelectItem
|
|
||||||
key={server.serverId}
|
|
||||||
value={server.serverId}
|
|
||||||
>
|
|
||||||
{server.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="removeSourceData"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<FormControl>
|
|
||||||
<Checkbox
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel className="ml-2">
|
|
||||||
Remove volumes from the source server after the
|
|
||||||
transfer
|
|
||||||
</FormLabel>
|
|
||||||
</div>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<AlertBlock type="warning">
|
|
||||||
<ul className="list-disc pl-4 space-y-1">
|
|
||||||
<li>The service is stopped while its data is copied.</li>
|
|
||||||
<li>
|
|
||||||
Point your DNS records to the new server, certificates are
|
|
||||||
issued again there.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Networks that only exist on the current server are
|
|
||||||
detached.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Bind mount host paths are copied but never deleted from
|
|
||||||
the source server.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</AlertBlock>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="secondary" onClick={() => setIsOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
{targets.length > 0 && (
|
|
||||||
<Button
|
|
||||||
isLoading={isTransferring}
|
|
||||||
form="hook-form-transfer-service"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Transfer
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
<DrawerLogs
|
|
||||||
isOpen={isDrawerOpen}
|
|
||||||
onClose={() => setIsDrawerOpen(false)}
|
|
||||||
filteredLogs={logs}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -91,32 +91,8 @@ export const PorkbunIcon = ({ className }: Props) => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const InfomaniakIcon = ({ className }: Props) => (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<path d="M2.4 0A2.395 2.395 0 0 0 0 2.4v19.2C0 22.9296 1.0704 24 2.4 24h19.2c1.3296 0 2.4-1.0704 2.4-2.4V2.4C24 1.0704 22.9296 0 21.6 0H10.112v11.7119l3.648-4.128h6l-4.58 4.3506 4.868 8.1296h-5.52l-2.5938-5.0211L10.112 16.8v3.264H5.12V0Z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const OvhIcon = ({ className }: Props) => (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
<path d="M19.881 10.095l2.563-4.45C23.434 7.389 24 9.404 24 11.555c0 2.88-1.017 5.523-2.71 7.594h-6.62l2.04-3.541h-2.696l3.176-5.513h2.691zm-2.32-5.243L9.333 19.14l.003.009H2.709C1.014 17.077 0 14.435 0 11.555c0-2.152.57-4.17 1.561-5.918L5.855 13.1 10.6 4.852h6.961z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const dnsProviderIcons = {
|
export const dnsProviderIcons = {
|
||||||
cloudflare: CloudflareIcon,
|
cloudflare: CloudflareIcon,
|
||||||
route53: Route53Icon,
|
route53: Route53Icon,
|
||||||
porkbun: PorkbunIcon,
|
porkbun: PorkbunIcon,
|
||||||
infomaniak: InfomaniakIcon,
|
|
||||||
ovh: OvhIcon,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -608,7 +608,6 @@ export const vaultProviderIcons = {
|
|||||||
hashicorp: HashicorpVaultIcon,
|
hashicorp: HashicorpVaultIcon,
|
||||||
infisical: InfisicalIcon,
|
infisical: InfisicalIcon,
|
||||||
aws: AwsIcon,
|
aws: AwsIcon,
|
||||||
"aws-parameter-store": AwsIcon,
|
|
||||||
doppler: DopplerIcon,
|
doppler: DopplerIcon,
|
||||||
azure: AzureIcon,
|
azure: AzureIcon,
|
||||||
scaleway: ScalewayIcon,
|
scaleway: ScalewayIcon,
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||||
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
||||||
import { useHubSpotChat } from "../shared/analytics";
|
import { HubSpotWidget } from "../shared/HubSpotWidget";
|
||||||
import Page from "./side";
|
import Page from "./side";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -13,7 +11,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DashboardLayout = ({ children, metaName }: Props) => {
|
export const DashboardLayout = ({ children, metaName }: Props) => {
|
||||||
const router = useRouter();
|
|
||||||
const { data: haveRootAccess } = api.user.haveRootAccess.useQuery();
|
const { data: haveRootAccess } = api.user.haveRootAccess.useQuery();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { config: whitelabeling } = useWhitelabeling();
|
const { config: whitelabeling } = useWhitelabeling();
|
||||||
@@ -26,22 +23,6 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isChatEnabled = isCloud === true && currentPlan === "startup";
|
const isChatEnabled = isCloud === true && currentPlan === "startup";
|
||||||
useHubSpotChat(isChatEnabled);
|
|
||||||
|
|
||||||
const { data: onboardingStatus } = api.project.onboardingStatus.useQuery();
|
|
||||||
const shouldRedirectToOnboarding =
|
|
||||||
router.pathname !== "/dashboard/home" &&
|
|
||||||
onboardingStatus?.shouldShowOnboarding === true;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (shouldRedirectToOnboarding) {
|
|
||||||
router.replace("/dashboard/home");
|
|
||||||
}
|
|
||||||
}, [shouldRedirectToOnboarding, router]);
|
|
||||||
|
|
||||||
if (shouldRedirectToOnboarding) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -53,6 +34,12 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
|
|||||||
</Head>
|
</Head>
|
||||||
)}
|
)}
|
||||||
<Page>{children}</Page>
|
<Page>{children}</Page>
|
||||||
|
{isChatEnabled && (
|
||||||
|
<>
|
||||||
|
<HubSpotWidget />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{haveRootAccess === true && <ImpersonationBar />}
|
{haveRootAccess === true && <ImpersonationBar />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { TruncateTooltip } from "@/components/shared/truncate-tooltip";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
@@ -101,7 +100,6 @@ import { authClient } from "@/lib/auth-client";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { AppRouter } from "@/server/api/root";
|
import type { AppRouter } from "@/server/api/root";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { TrialBanner } from "../dashboard/billing/trial-banner";
|
|
||||||
import { AddOrganization } from "../dashboard/organization/handle-organization";
|
import { AddOrganization } from "../dashboard/organization/handle-organization";
|
||||||
import { DialogAction } from "../shared/dialog-action";
|
import { DialogAction } from "../shared/dialog-action";
|
||||||
import { Logo } from "../shared/logo";
|
import { Logo } from "../shared/logo";
|
||||||
@@ -618,7 +616,7 @@ function SidebarLogo() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Organization Logo and Selector */}
|
{/* Organization Logo and Selector */}
|
||||||
<SidebarMenuItem className={"w-full min-w-0"}>
|
<SidebarMenuItem className={"w-full"}>
|
||||||
<Popover
|
<Popover
|
||||||
open={organizationSelectorOpen}
|
open={organizationSelectorOpen}
|
||||||
onOpenChange={setOrganizationSelectorOpen}
|
onOpenChange={setOrganizationSelectorOpen}
|
||||||
@@ -634,13 +632,14 @@ function SidebarLogo() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-w-0 flex-1 items-center gap-2",
|
"flex items-center gap-2",
|
||||||
isCollapsed && "justify-center",
|
isCollapsed && "justify-center",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-6 shrink-0 items-center justify-center rounded-sm border",
|
"flex items-center justify-center rounded-sm border",
|
||||||
|
"size-6",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Logo
|
<Logo
|
||||||
@@ -653,27 +652,22 @@ function SidebarLogo() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-start min-w-0 flex-1",
|
"flex flex-col items-start",
|
||||||
isCollapsed && "hidden",
|
isCollapsed && "hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5 min-w-0 w-full">
|
<div className="flex items-center gap-1.5">
|
||||||
<TruncateTooltip
|
<p className="text-sm font-medium leading-none">
|
||||||
text={
|
{activeOrganization?.name ?? "Select Organization"}
|
||||||
activeOrganization?.name ?? "Select Organization"
|
</p>
|
||||||
}
|
|
||||||
className="text-sm font-medium"
|
|
||||||
/>
|
|
||||||
{haveValidLicense && (
|
{haveValidLicense && (
|
||||||
<Badge variant="blue" className="shrink-0">
|
<Badge variant="blue">Enterprise</Badge>
|
||||||
Enterprise
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronsUpDown
|
<ChevronsUpDown
|
||||||
className={cn("ml-auto shrink-0", isCollapsed && "hidden")}
|
className={cn("ml-auto", isCollapsed && "hidden")}
|
||||||
/>
|
/>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -1230,7 +1224,6 @@ export default function Page({ children }: Props) {
|
|||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
{isCloud === true && <TrialBanner />}
|
|
||||||
{!includesProjects && (
|
{!includesProjects && (
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||||
<div className="flex items-center justify-between w-full px-4">
|
<div className="flex items-center justify-between w-full px-4">
|
||||||
|
|||||||
@@ -83,11 +83,6 @@ const azureMapping: ClaimMapping = {
|
|||||||
image: "",
|
image: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
// id: "sub",
|
|
||||||
// email: "preferred_username",
|
|
||||||
// emailVerified: "email_verified",
|
|
||||||
// name: "name",
|
|
||||||
|
|
||||||
const genericMapping: ClaimMapping = {
|
const genericMapping: ClaimMapping = {
|
||||||
id: "sub",
|
id: "sub",
|
||||||
email: "email",
|
email: "email",
|
||||||
@@ -233,9 +228,10 @@ export function RegisterOidcDialog({
|
|||||||
mapping: {
|
mapping: {
|
||||||
id: oidc?.mapping?.id ?? baseMapping.id,
|
id: oidc?.mapping?.id ?? baseMapping.id,
|
||||||
email: oidc?.mapping?.email ?? baseMapping.email,
|
email: oidc?.mapping?.email ?? baseMapping.email,
|
||||||
emailVerified: oidc?.mapping?.emailVerified ?? "",
|
emailVerified:
|
||||||
|
oidc?.mapping?.emailVerified ?? baseMapping.emailVerified,
|
||||||
name: oidc?.mapping?.name ?? baseMapping.name,
|
name: oidc?.mapping?.name ?? baseMapping.name,
|
||||||
image: oidc?.mapping?.image ?? "",
|
image: oidc?.mapping?.image ?? baseMapping.image,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [data, open, form]);
|
}, [data, open, form]);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Head from "next/head";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export function WhitelabelingProvider() {
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, {
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const faviconHref =
|
||||||
|
config?.faviconUrl ??
|
||||||
|
(resolvedTheme === "dark"
|
||||||
|
? "/icon-dark.svg"
|
||||||
|
: resolvedTheme === "light"
|
||||||
|
? "/icon-light.svg"
|
||||||
|
: "/icon.svg");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
{config?.metaTitle && <title>{config.metaTitle}</title>}
|
||||||
|
<link rel="icon" href={faviconHref} key="app-favicon" />
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
{config?.customCss && (
|
||||||
|
<style
|
||||||
|
id="whitelabeling-styles"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: config.customCss,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,8 +47,8 @@ const formSchema = z.object({
|
|||||||
docsUrl: safeUrlField,
|
docsUrl: safeUrlField,
|
||||||
errorPageTitle: z.string(),
|
errorPageTitle: z.string(),
|
||||||
errorPageDescription: z.string(),
|
errorPageDescription: z.string(),
|
||||||
|
metaTitle: z.string(),
|
||||||
footerText: z.string(),
|
footerText: z.string(),
|
||||||
ogImageUrl: safeUrlField,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormSchema = z.infer<typeof formSchema>;
|
type FormSchema = z.infer<typeof formSchema>;
|
||||||
@@ -193,8 +193,8 @@ export function WhitelabelingSettings() {
|
|||||||
docsUrl: "",
|
docsUrl: "",
|
||||||
errorPageTitle: "",
|
errorPageTitle: "",
|
||||||
errorPageDescription: "",
|
errorPageDescription: "",
|
||||||
|
metaTitle: "",
|
||||||
footerText: "",
|
footerText: "",
|
||||||
ogImageUrl: "",
|
|
||||||
},
|
},
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
});
|
});
|
||||||
@@ -212,8 +212,8 @@ export function WhitelabelingSettings() {
|
|||||||
docsUrl: data.docsUrl ?? "",
|
docsUrl: data.docsUrl ?? "",
|
||||||
errorPageTitle: data.errorPageTitle ?? "",
|
errorPageTitle: data.errorPageTitle ?? "",
|
||||||
errorPageDescription: data.errorPageDescription ?? "",
|
errorPageDescription: data.errorPageDescription ?? "",
|
||||||
|
metaTitle: data.metaTitle ?? "",
|
||||||
footerText: data.footerText ?? "",
|
footerText: data.footerText ?? "",
|
||||||
ogImageUrl: data.ogImageUrl ?? "",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form]);
|
||||||
@@ -242,8 +242,8 @@ export function WhitelabelingSettings() {
|
|||||||
docsUrl: values.docsUrl || null,
|
docsUrl: values.docsUrl || null,
|
||||||
errorPageTitle: values.errorPageTitle || null,
|
errorPageTitle: values.errorPageTitle || null,
|
||||||
errorPageDescription: values.errorPageDescription || null,
|
errorPageDescription: values.errorPageDescription || null,
|
||||||
|
metaTitle: values.metaTitle || null,
|
||||||
footerText: values.footerText || null,
|
footerText: values.footerText || null,
|
||||||
ogImageUrl: values.ogImageUrl || null,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -388,27 +388,6 @@ export function WhitelabelingSettings() {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="ogImageUrl"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>OG Image URL</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="https://example.com/og.png"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Open Graph image used for link previews on social media
|
|
||||||
and messaging platforms. Recommended size: 1200x630px.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -462,15 +441,32 @@ export function WhitelabelingSettings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Links Section */}
|
{/* Metadata & Links Section */}
|
||||||
<Card className="bg-transparent">
|
<Card className="bg-transparent">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Links</CardTitle>
|
<CardTitle>Metadata & Links</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Customize the footer text and sidebar links.
|
Customize the page title, footer text, and sidebar links.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metaTitle"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Page Title</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Dokploy" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Browser tab title. Defaults to "Dokploy" if empty.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="footerText"
|
name="footerText"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import Script from "next/script";
|
||||||
|
|
||||||
|
export const HubSpotWidget = () => {
|
||||||
|
return (
|
||||||
|
<Script
|
||||||
|
id="hs-script-loader"
|
||||||
|
type="text/javascript"
|
||||||
|
src="//js-eu1.hs-scripts.com/147033433.js"
|
||||||
|
strategy="lazyOnload"
|
||||||
|
async
|
||||||
|
defer
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -92,11 +92,5 @@ export const useHubSpotChat = (enabled: boolean) => {
|
|||||||
loadWidget,
|
loadWidget,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return () => {
|
|
||||||
window.hsConversationsOnReady = window.hsConversationsOnReady?.filter(
|
|
||||||
(callback) => callback !== loadWidget,
|
|
||||||
);
|
|
||||||
window.HubSpotConversations?.widget.remove();
|
|
||||||
};
|
|
||||||
}, [enabled]);
|
}, [enabled]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ const dockerComposeServices = [
|
|||||||
{ label: "networks", type: "keyword", info: "Define networks" },
|
{ label: "networks", type: "keyword", info: "Define networks" },
|
||||||
{ label: "configs", type: "keyword", info: "Define configuration files" },
|
{ label: "configs", type: "keyword", info: "Define configuration files" },
|
||||||
{ label: "secrets", type: "keyword", info: "Define secrets" },
|
{ label: "secrets", type: "keyword", info: "Define secrets" },
|
||||||
{ label: "models", type: "keyword", info: "Define AI models" },
|
|
||||||
].map((opt) => ({
|
].map((opt) => ({
|
||||||
...opt,
|
...opt,
|
||||||
apply: (
|
apply: (
|
||||||
@@ -84,11 +83,6 @@ const dockerComposeServiceOptions = [
|
|||||||
},
|
},
|
||||||
{ label: "restart", type: "keyword", info: "Restart policy" },
|
{ label: "restart", type: "keyword", info: "Restart policy" },
|
||||||
{ label: "networks", type: "keyword", info: "Networks to join" },
|
{ label: "networks", type: "keyword", info: "Networks to join" },
|
||||||
{
|
|
||||||
label: "models",
|
|
||||||
type: "keyword",
|
|
||||||
info: "AI models to use, referencing top-level models",
|
|
||||||
},
|
|
||||||
].map((opt) => ({
|
].map((opt) => ({
|
||||||
...opt,
|
...opt,
|
||||||
apply: (
|
apply: (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user