mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 21:31:11 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54cadc3b8d |
@@ -16,7 +16,7 @@ No instance is running yet — start your own, isolated to this worktree:
|
||||
until it answers (usually ~10-15s).
|
||||
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
|
||||
|
||||
Note: `mcp__dokploy__*` resolves its URL from
|
||||
Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from
|
||||
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
|
||||
discovered mid-session. If those tools are unavailable or point at the wrong
|
||||
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
|
||||
|
||||
@@ -14,34 +14,34 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get version from package.json
|
||||
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> "$GITHUB_ENV"
|
||||
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> $GITHUB_ENV
|
||||
|
||||
- name: Get latest GitHub tag
|
||||
run: |
|
||||
LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1)
|
||||
echo "LATEST_TAG=$LATEST_TAG" >> "$GITHUB_ENV"
|
||||
echo "$LATEST_TAG"
|
||||
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV
|
||||
echo $LATEST_TAG
|
||||
- name: Compare versions
|
||||
run: |
|
||||
if [ "$VERSION" != "$LATEST_TAG" ]; then
|
||||
if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then
|
||||
VERSION_CHANGED="true"
|
||||
else
|
||||
VERSION_CHANGED="false"
|
||||
fi
|
||||
echo "VERSION_CHANGED=$VERSION_CHANGED" >> "$GITHUB_ENV"
|
||||
echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV
|
||||
echo "Comparing versions:"
|
||||
echo "Current version: $VERSION"
|
||||
echo "Latest tag: $LATEST_TAG"
|
||||
echo "Current version: ${{ env.VERSION }}"
|
||||
echo "Latest tag: ${{ env.LATEST_TAG }}"
|
||||
echo "Version changed: $VERSION_CHANGED"
|
||||
- name: Check if a PR already exists
|
||||
run: |
|
||||
PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length')
|
||||
echo "PR_EXISTS=$PR_EXISTS" >> "$GITHUB_ENV"
|
||||
echo "PR_EXISTS=$PR_EXISTS" >> $GITHUB_ENV
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_PAT }}
|
||||
|
||||
@@ -56,12 +56,12 @@ jobs:
|
||||
git push origin canary
|
||||
|
||||
gh pr create \
|
||||
--title "🚀 Release $VERSION" \
|
||||
--body "
|
||||
This PR promotes changes from \`canary\` to \`main\` for version $VERSION.
|
||||
--title "🚀 Release ${{ env.VERSION }}" \
|
||||
--body '
|
||||
This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}.
|
||||
|
||||
### 🔍 Changes Include:
|
||||
- Version bump to $VERSION
|
||||
- Version bump to ${{ env.VERSION }}
|
||||
- All changes from canary branch
|
||||
|
||||
### ✅ Pre-merge Checklist:
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
- [ ] Documentation updated
|
||||
- [ ] Docker images built and tested
|
||||
|
||||
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)" \
|
||||
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)' \
|
||||
--base main \
|
||||
--head canary \
|
||||
--label "release" --label "automated pr" || true \
|
||||
|
||||
@@ -5,38 +5,33 @@ on:
|
||||
branches: [main, canary]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-push-cloud-image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set tag and version
|
||||
id: meta-cloud
|
||||
run: |
|
||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tags=siumauricio/cloud:canary" >> "$GITHUB_OUTPUT"
|
||||
echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.cloud
|
||||
@@ -53,28 +48,26 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set tag and version
|
||||
id: meta-schedule
|
||||
run: |
|
||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tags=siumauricio/schedule:canary" >> "$GITHUB_OUTPUT"
|
||||
echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.schedule
|
||||
@@ -87,28 +80,26 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set tag and version
|
||||
id: meta-server
|
||||
run: |
|
||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tags=siumauricio/server:canary" >> "$GITHUB_OUTPUT"
|
||||
echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.server
|
||||
|
||||
@@ -8,23 +8,18 @@ on:
|
||||
env:
|
||||
IMAGE_NAME: dokploy/dokploy
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker-amd:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -32,15 +27,15 @@ jobs:
|
||||
- name: Set tag and version
|
||||
id: meta
|
||||
run: |
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
TAG="latest"
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
else
|
||||
TAG="feature"
|
||||
fi
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Prepare env file
|
||||
run: |
|
||||
@@ -48,7 +43,7 @@ jobs:
|
||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
@@ -58,15 +53,13 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -75,15 +68,15 @@ jobs:
|
||||
id: meta
|
||||
run: |
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
TAG="latest"
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
else
|
||||
TAG="feature"
|
||||
fi
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Prepare env file
|
||||
run: |
|
||||
@@ -91,7 +84,7 @@ jobs:
|
||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
@@ -103,22 +96,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifests
|
||||
run: |
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
TAG="latest"
|
||||
|
||||
@@ -130,7 +121,7 @@ jobs:
|
||||
${IMAGE_NAME}:${TAG}-amd64 \
|
||||
${IMAGE_NAME}:${TAG}-arm64
|
||||
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||
${IMAGE_NAME}:${TAG}-amd64 \
|
||||
@@ -147,37 +138,32 @@ jobs:
|
||||
needs: [combine-manifests]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
npm_version: ${{ steps.get_version.outputs.npm_version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "npm_version=${VERSION#v}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Fetch install.sh
|
||||
env:
|
||||
RELEASE_VERSION: ${{ steps.get_version.outputs.version }}
|
||||
run: |
|
||||
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
|
||||
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
|
||||
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
|
||||
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${RELEASE_VERSION}}\""; tail -n +2 install.sh; } > install-pinned.sh
|
||||
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
|
||||
mv install-pinned.sh install.sh
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.get_version.outputs.version }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
@@ -193,19 +179,15 @@ jobs:
|
||||
needs: [generate-release]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NPM_VERSION: ${{ needs.generate-release.outputs.npm_version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.22.0
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.4.0
|
||||
cache: pnpm
|
||||
@@ -220,63 +202,63 @@ jobs:
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
|
||||
cd /tmp/mcp-repo
|
||||
|
||||
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
||||
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
cp $GITHUB_WORKSPACE/openapi.json src/generated/openapi.json
|
||||
cp ${{ github.workspace }}/openapi.json src/generated/openapi.json
|
||||
pnpm install
|
||||
pnpm run generate
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
git add -A
|
||||
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
--allow-empty
|
||||
git push
|
||||
|
||||
echo "✅ MCP repo synced to version $NPM_VERSION"
|
||||
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||
|
||||
- name: Sync version to CLI repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
|
||||
cd /tmp/cli-repo
|
||||
|
||||
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
||||
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
|
||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
||||
pnpm install
|
||||
pnpm run generate
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
git add -A
|
||||
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
--allow-empty
|
||||
git push
|
||||
|
||||
echo "✅ CLI repo synced to version $NPM_VERSION"
|
||||
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||
|
||||
- name: Sync version to SDK repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
|
||||
cd /tmp/sdk-repo
|
||||
|
||||
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
||||
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
|
||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
||||
pnpm install
|
||||
pnpm run generate
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
git add -A
|
||||
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
--allow-empty
|
||||
git push
|
||||
|
||||
echo "✅ SDK repo synced to version $NPM_VERSION"
|
||||
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||
|
||||
@@ -6,20 +6,15 @@ on:
|
||||
pull_request:
|
||||
branches: [canary]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup biomeJs
|
||||
uses: biomejs/setup-biome@v2
|
||||
uses: biomejs/setup-biome@v2
|
||||
|
||||
- name: Run Biome formatter
|
||||
run: biome format --write
|
||||
|
||||
@@ -16,19 +16,17 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
||||
|
||||
- name: Cherry-pick fix to main
|
||||
env:
|
||||
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
run: |
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
SHA="$MERGE_SHA"
|
||||
SHA="${{ github.event.pull_request.merge_commit_sha }}"
|
||||
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
|
||||
git cherry-pick -x -m 1 "$SHA"
|
||||
else
|
||||
|
||||
@@ -7,16 +7,12 @@ concurrency:
|
||||
group: hotfix-to-main
|
||||
cancel-in-progress: false
|
||||
|
||||
# Push to main goes through HOTFIX_PUSH_TOKEN, not the workflow token.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
||||
|
||||
@@ -7,23 +7,18 @@ on:
|
||||
env:
|
||||
IMAGE_NAME: dokploy/monitoring
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker-amd:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -31,17 +26,17 @@ jobs:
|
||||
- name: Set tag
|
||||
id: meta
|
||||
run: |
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
TAG="latest"
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
else
|
||||
TAG="feature"
|
||||
fi
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.monitoring
|
||||
@@ -52,15 +47,13 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -68,17 +61,17 @@ jobs:
|
||||
- name: Set
|
||||
id: meta
|
||||
run: |
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
TAG="latest"
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
else
|
||||
TAG="feature"
|
||||
fi
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.monitoring
|
||||
@@ -91,29 +84,27 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifests
|
||||
run: |
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
TAG="latest"
|
||||
|
||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||
${IMAGE_NAME}:${TAG}-amd64 \
|
||||
${IMAGE_NAME}:${TAG}-arm64
|
||||
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||
${IMAGE_NAME}:${TAG}-amd64 \
|
||||
|
||||
@@ -14,11 +14,9 @@ jobs:
|
||||
matrix:
|
||||
job: [build, test, typecheck]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24.4.0
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -12,20 +12,17 @@ on:
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
generate-and-commit:
|
||||
name: Generate OpenAPI and commit to Dokploy repo
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Dokploy repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.4.0
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -28,9 +28,6 @@ env:
|
||||
DOKPLOY_SERVICE: dokploy
|
||||
DOKPLOY_PORT: 3000
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
jobs:
|
||||
|
||||
@@ -424,10 +421,8 @@ jobs:
|
||||
# ── Job summary ────────────────────────────────────────────────────────
|
||||
- name: Write job summary
|
||||
if: always()
|
||||
env:
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
run: |
|
||||
STATUS="$JOB_STATUS"
|
||||
STATUS="${{ job.status }}"
|
||||
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
|
||||
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
|
||||
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
|
||||
|
||||
+2
-2
@@ -123,13 +123,13 @@ pnpm run docker:push
|
||||
In the case you lost your password, you can reset the owner's password using the following command
|
||||
|
||||
```bash
|
||||
pnpm --filter=dokploy run reset-password
|
||||
pnpm run reset-password
|
||||
```
|
||||
|
||||
To reset the password of a specific user instead, pass their email as an argument
|
||||
|
||||
```bash
|
||||
pnpm --filter=dokploy run reset-password user@example.com
|
||||
pnpm run reset-password -- user@example.com
|
||||
```
|
||||
|
||||
Both commands print the new randomly generated password to the console.
|
||||
|
||||
@@ -26,7 +26,6 @@ Dokploy includes multiple features to make your life easier.
|
||||
- **Templates**: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click.
|
||||
- **Traefik Integration**: Automatically integrates with Traefik for routing and load balancing.
|
||||
- **Real-time Monitoring**: Monitor CPU, memory, storage, and network usage for every resource.
|
||||
- **Logs & Metrics**: Ship container logs and host/container metrics to Grafana (Loki, Prometheus/Mimir), Datadog, New Relic, InfluxDB, VictoriaMetrics, Better Stack, Elasticsearch/OpenSearch, Splunk, or AWS CloudWatch, tagged by project/environment/application.
|
||||
- **Docker Management**: Easily deploy and manage Docker containers.
|
||||
- **CLI/API**: Manage your applications and databases using the command line or through the API.
|
||||
- **Notifications**: Get notified when your deployments succeed or fail (via Slack, Discord, Telegram, Email, etc.).
|
||||
|
||||
@@ -6,13 +6,3 @@ npm run dev
|
||||
```
|
||||
open http://localhost:3000
|
||||
```
|
||||
|
||||
## Inngest
|
||||
|
||||
This service uses Inngest to queue deployments. Run the local dev server in a separate terminal:
|
||||
|
||||
```
|
||||
npm run dev:inngest
|
||||
```
|
||||
|
||||
It syncs with the app at `http://localhost:4000/api/inngest` and serves its dashboard at `http://localhost:8288`.
|
||||
|
||||
@@ -4,18 +4,17 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "PORT=4000 tsx watch src/index.ts",
|
||||
"dev:inngest": "npx --yes inngest-cli@1.9.0 dev -u http://localhost:4000/api/inngest --no-discovery",
|
||||
"build": "rimraf dist && tsc --project tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"inngest": "3.54.0",
|
||||
"inngest": "3.40.1",
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@hono/node-server": "^1.19.15",
|
||||
"@hono/node-server": "^1.14.3",
|
||||
"@hono/zod-validator": "0.7.6",
|
||||
"dotenv": "^16.4.5",
|
||||
"hono": "^4.13.5",
|
||||
"hono": "^4.11.7",
|
||||
"pino": "9.4.0",
|
||||
"pino-pretty": "11.2.2",
|
||||
"react": "19.2.7",
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockUpdateApplication = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@dokploy/server/services/permission", () => {
|
||||
return {
|
||||
checkServicePermissionAndAccess: vi.fn(async () => undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/index", () => ({
|
||||
IS_CLOUD: true,
|
||||
hasValidLicense: vi.fn(async () => false),
|
||||
updateApplication: mockUpdateApplication,
|
||||
findApplicationById: vi.fn(async () => ({
|
||||
applicationId: "app-1",
|
||||
appName: "example-app",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/lib/auth", () => ({
|
||||
validateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/api/utils/audit", () => ({
|
||||
audit: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const { applicationRouter } = await import("@/server/api/routers/application");
|
||||
|
||||
const caller = applicationRouter.createCaller({
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-1", email: "user@example.com", role: "owner" },
|
||||
} as Parameters<typeof applicationRouter.createCaller>[0]);
|
||||
|
||||
const providerChanges = [
|
||||
{
|
||||
name: "GitHub",
|
||||
sourceType: "github",
|
||||
save: () =>
|
||||
caller.saveGithubProvider({
|
||||
applicationId: "app-1",
|
||||
repository: "example-repo",
|
||||
owner: "example-owner",
|
||||
branch: "main",
|
||||
buildPath: "/",
|
||||
githubId: "provider-1",
|
||||
watchPaths: ["src/**"],
|
||||
triggerType: "push",
|
||||
enableSubmodules: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "GitLab",
|
||||
sourceType: "gitlab",
|
||||
save: () =>
|
||||
caller.saveGitlabProvider({
|
||||
applicationId: "app-1",
|
||||
gitlabRepository: "example-repo",
|
||||
gitlabOwner: "example-owner",
|
||||
gitlabBranch: "main",
|
||||
gitlabBuildPath: "/",
|
||||
gitlabId: "provider-1",
|
||||
gitlabProjectId: 1,
|
||||
gitlabPathNamespace: "example-owner/example-repo",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Bitbucket",
|
||||
sourceType: "bitbucket",
|
||||
save: () =>
|
||||
caller.saveBitbucketProvider({
|
||||
applicationId: "app-1",
|
||||
bitbucketRepository: "example-repo",
|
||||
bitbucketRepositorySlug: "example-repo",
|
||||
bitbucketOwner: "example-owner",
|
||||
bitbucketBranch: "main",
|
||||
bitbucketBuildPath: "/",
|
||||
bitbucketId: "provider-1",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Gitea",
|
||||
sourceType: "gitea",
|
||||
save: () =>
|
||||
caller.saveGiteaProvider({
|
||||
applicationId: "app-1",
|
||||
giteaRepository: "example-repo",
|
||||
giteaOwner: "example-owner",
|
||||
giteaBranch: "main",
|
||||
giteaBuildPath: "/",
|
||||
giteaId: "provider-1",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Docker",
|
||||
sourceType: "docker",
|
||||
save: () =>
|
||||
caller.saveDockerProvider({
|
||||
applicationId: "app-1",
|
||||
dockerImage: "nginx:alpine",
|
||||
username: "",
|
||||
password: "",
|
||||
registryUrl: "",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "custom Git",
|
||||
sourceType: "git",
|
||||
save: () =>
|
||||
caller.saveGitProvider({
|
||||
applicationId: "app-1",
|
||||
customGitUrl: "https://example.com/repo.git",
|
||||
customGitBranch: "main",
|
||||
customGitBuildPath: "/",
|
||||
customGitSSHKeyId: "key-1",
|
||||
watchPaths: ["src/**"],
|
||||
enableSubmodules: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "disconnect Git provider",
|
||||
sourceType: "github",
|
||||
save: () => caller.disconnectGitProvider({ applicationId: "app-1" }),
|
||||
},
|
||||
];
|
||||
|
||||
describe("saving an application provider", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each(providerChanges)(
|
||||
"keeps the status when $name settings change",
|
||||
async ({ save, sourceType }) => {
|
||||
for (const status of ["done", "running"]) {
|
||||
mockUpdateApplication.mockClear();
|
||||
let savedStatus = status;
|
||||
mockUpdateApplication.mockImplementation(async (_id, update) => {
|
||||
if (update.applicationStatus !== undefined) {
|
||||
savedStatus = update.applicationStatus;
|
||||
}
|
||||
});
|
||||
|
||||
await save();
|
||||
|
||||
expect(savedStatus).toBe(status);
|
||||
expect(mockUpdateApplication).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateApplication).toHaveBeenCalledWith(
|
||||
"app-1",
|
||||
expect.objectContaining({ sourceType }),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { Destination } from "@dokploy/server/services/destination";
|
||||
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
|
||||
import { getS3Credentials } from "@dokploy/server/utils/backups/utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("redactRcloneCredentials (#4621)", () => {
|
||||
@@ -50,48 +48,3 @@ describe("redactRcloneCredentials (#4621)", () => {
|
||||
expect(redacted).toContain("[REDACTED]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactRcloneCredentials with getS3Credentials output (#5519)", () => {
|
||||
const buildCommand = (accessKey: string, secretAccessKey: string) =>
|
||||
`rclone rcat ${getS3Credentials({
|
||||
accessKey,
|
||||
secretAccessKey,
|
||||
region: "us-west-001",
|
||||
endpoint: "https://s3.us-west-001.backblazeb2.com",
|
||||
provider: "Other",
|
||||
} as Destination).join(" ")} :s3:bucket/file.gz`;
|
||||
|
||||
it.each([
|
||||
[
|
||||
"plain alphanumeric",
|
||||
"001aaaabbbbccccdd0000000001",
|
||||
"K001FAKEfakeFAKEfake",
|
||||
],
|
||||
[
|
||||
"containing slashes",
|
||||
"AKIAIOSFODNN7EXAMPLE",
|
||||
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
],
|
||||
["containing spaces", "key with space", "secret with space"],
|
||||
["containing quotes", "it's-a-key", `say "hi" it's`],
|
||||
["containing shell metacharacters", "key$HOME", "sec;ret`id`&|\\x"],
|
||||
])("redacts credentials %s", (_, accessKey, secretAccessKey) => {
|
||||
const redacted = redactRcloneCredentials(
|
||||
buildCommand(accessKey, secretAccessKey),
|
||||
);
|
||||
expect(redacted).toContain(
|
||||
'--s3-access-key-id="[REDACTED]" --s3-secret-access-key="[REDACTED]" --s3-region=us-west-001',
|
||||
);
|
||||
expect(redacted).not.toContain(accessKey);
|
||||
expect(redacted).not.toContain(secretAccessKey);
|
||||
expect(redacted).toContain(":s3:bucket/file.gz");
|
||||
});
|
||||
|
||||
it("redacts credentials embedded in an error string", () => {
|
||||
const errorStr = `Error: Command failed: ${buildCommand("MYKEY", "MY/SECRET")}`;
|
||||
const redacted = redactRcloneCredentials(errorStr);
|
||||
expect(redacted).not.toContain("MYKEY");
|
||||
expect(redacted).not.toContain("MY/SECRET");
|
||||
expect(redacted).toContain("[REDACTED]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("Remote Server Action Tooltips", () => {
|
||||
it("should close the tooltip when the dialog trigger is clicked", () => {
|
||||
// Note: The UI layer in this project is not currently tested with DOM simulation tools
|
||||
// like React Testing Library or JSDOM.
|
||||
//
|
||||
// The structural fix implemented (passing TooltipTrigger asChild -> DialogTrigger asChild)
|
||||
// inherently guarantees that Radix UI's composeEventHandlers properly propagates the
|
||||
// onPointerDown event to dismiss the tooltip simultaneously as the dialog opens.
|
||||
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { createCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const base = {
|
||||
composeType: "docker-compose" as const,
|
||||
appName: "compose-app",
|
||||
sourceType: "github" as const,
|
||||
composePath: "./docker-compose.yml",
|
||||
command: "",
|
||||
};
|
||||
|
||||
describe("compose createCommand --pull always", () => {
|
||||
it("adds --pull always when pullImages is enabled", () => {
|
||||
const cmd = createCommand({ ...base, pullImages: true } as any);
|
||||
|
||||
expect(cmd).toContain("up -d --build --remove-orphans --pull always");
|
||||
});
|
||||
|
||||
it("omits --pull when pullImages is disabled", () => {
|
||||
const cmd = createCommand({ ...base, pullImages: false } as any);
|
||||
|
||||
expect(cmd).not.toContain("--pull");
|
||||
});
|
||||
|
||||
it("does not add --pull to stack deploy", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
composeType: "stack",
|
||||
pullImages: true,
|
||||
} as any);
|
||||
|
||||
expect(cmd).not.toContain("--pull");
|
||||
});
|
||||
|
||||
it("leaves a custom command untouched", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
command: "compose -p compose-app up -d",
|
||||
pullImages: true,
|
||||
} as any);
|
||||
|
||||
expect(cmd).toBe("compose -p compose-app up -d");
|
||||
});
|
||||
});
|
||||
@@ -33,16 +33,8 @@ services:
|
||||
volumes:
|
||||
test-data:
|
||||
`,
|
||||
composeId: "compose-1",
|
||||
name: "raw-stack",
|
||||
composePath: "./docker-compose.yml",
|
||||
composeType: "stack",
|
||||
environment: {
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
projectId: "project-1",
|
||||
project: { name: "shop", organizationId: "org-1" },
|
||||
},
|
||||
isolatedDeployment: false,
|
||||
isolatedDeploymentsVolume: false,
|
||||
randomize: false,
|
||||
@@ -62,72 +54,6 @@ volumes:
|
||||
},
|
||||
]);
|
||||
expect(converted?.services?.test?.tmpfs).toEqual(["/cache"]);
|
||||
expect(converted?.services?.test?.labels).toEqual(
|
||||
expect.arrayContaining([
|
||||
"dokploy.organization.id=org-1",
|
||||
"dokploy.project=shop",
|
||||
"dokploy.service=test",
|
||||
]),
|
||||
);
|
||||
expect(converted?.services?.test?.deploy?.labels).toEqual(
|
||||
expect.arrayContaining(["dokploy.organization.id=org-1"]),
|
||||
);
|
||||
expect(execAsyncRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("escapes `$` in the Dokploy label values so compose keeps them literal", async () => {
|
||||
const compose = {
|
||||
appName: "dollar-stack",
|
||||
composeFile: `
|
||||
services:
|
||||
mapped:
|
||||
image: alpine:latest
|
||||
labels:
|
||||
custom: keep
|
||||
deploy:
|
||||
labels:
|
||||
custom: keep
|
||||
listed:
|
||||
image: alpine:latest
|
||||
labels:
|
||||
- custom=keep
|
||||
`,
|
||||
composeId: "compose-1",
|
||||
name: "app $DB_PASSWORD",
|
||||
composePath: "./docker-compose.yml",
|
||||
composeType: "stack",
|
||||
environment: {
|
||||
environmentId: "env-1",
|
||||
name: "env ${HOME}",
|
||||
projectId: "project-1",
|
||||
project: { name: "Budget $100", organizationId: "org-1" },
|
||||
},
|
||||
isolatedDeployment: false,
|
||||
isolatedDeploymentsVolume: false,
|
||||
randomize: false,
|
||||
serverId: null,
|
||||
sourceType: "raw",
|
||||
suffix: "",
|
||||
} as unknown as Parameters<typeof addDomainToCompose>[0];
|
||||
|
||||
const converted = await addDomainToCompose(compose, []);
|
||||
const escaped = {
|
||||
"dokploy.project": "Budget $$100",
|
||||
"dokploy.environment": "env $${HOME}",
|
||||
"dokploy.application": "app $$DB_PASSWORD",
|
||||
};
|
||||
|
||||
const mapped = converted?.services?.mapped;
|
||||
expect(mapped?.labels).toMatchObject({ custom: "keep", ...escaped });
|
||||
expect(mapped?.deploy?.labels).toMatchObject({
|
||||
custom: "keep",
|
||||
...escaped,
|
||||
});
|
||||
const listed = converted?.services?.listed;
|
||||
const asList = Object.entries(escaped).map(([k, v]) => `${k}=${v}`);
|
||||
expect(listed?.labels).toEqual(
|
||||
expect.arrayContaining(["custom=keep", ...asList]),
|
||||
);
|
||||
expect(listed?.deploy?.labels).toEqual(expect.arrayContaining(asList));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,16 +54,7 @@ const inputEncoding: Record<string, string> = {
|
||||
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
|
||||
};
|
||||
|
||||
const hasDocker = () => {
|
||||
try {
|
||||
execFileSync("docker", ["info"], { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
describe.skipIf(!hasDocker())("getCreateEnvFileCommand", () => {
|
||||
describe("getCreateEnvFileCommand", () => {
|
||||
it("writes special environment values that Docker Compose reads back literally", () => {
|
||||
mkdirSync(codePath, { recursive: true });
|
||||
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import type { Compose, ComposeSpecification } from "@dokploy/server";
|
||||
import {
|
||||
addAppNameToPreventCollision,
|
||||
addSuffixToAllProperties,
|
||||
} from "@dokploy/server";
|
||||
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { stringify } from "yaml";
|
||||
import composeSpec from "../../../components/shared/compose-spec.json";
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>()),
|
||||
execAsyncRemote: vi.fn(),
|
||||
}));
|
||||
|
||||
const modelCompose = {
|
||||
services: {
|
||||
app: {
|
||||
image: "my-chat-app",
|
||||
models: ["llm"],
|
||||
},
|
||||
worker: {
|
||||
image: "my-worker",
|
||||
models: {
|
||||
llm: {
|
||||
endpoint_var: "LLM_URL",
|
||||
model_var: "LLM_MODEL",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
llm: {
|
||||
model: "ai/smollm2",
|
||||
context_size: 2048,
|
||||
runtime_flags: ["--verbose"],
|
||||
},
|
||||
},
|
||||
} satisfies ComposeSpecification;
|
||||
|
||||
test("bundled compose schema declares models", () => {
|
||||
expect(composeSpec.properties).toHaveProperty("models");
|
||||
expect(composeSpec.definitions.model.required).toEqual(["model"]);
|
||||
expect(composeSpec.definitions.service.properties).toHaveProperty("models");
|
||||
});
|
||||
|
||||
test("suffixing does not rename model identifiers or drop model config", () => {
|
||||
const updated = addSuffixToAllProperties(
|
||||
structuredClone(modelCompose),
|
||||
"testhash",
|
||||
);
|
||||
|
||||
expect(updated.models?.llm?.model).toBe("ai/smollm2");
|
||||
expect(updated.models?.llm?.context_size).toBe(2048);
|
||||
expect(updated.models?.llm?.runtime_flags).toEqual(["--verbose"]);
|
||||
expect(updated.services?.["app-testhash"]?.models).toEqual(["llm"]);
|
||||
expect(updated.services?.["worker-testhash"]?.models).toEqual({
|
||||
llm: {
|
||||
endpoint_var: "LLM_URL",
|
||||
model_var: "LLM_MODEL",
|
||||
},
|
||||
});
|
||||
expect(updated.services).not.toHaveProperty("app");
|
||||
expect(updated.models).not.toHaveProperty("llm-testhash");
|
||||
});
|
||||
|
||||
test("isolated deployment preserves model identifiers", () => {
|
||||
const updated = addAppNameToPreventCollision(
|
||||
structuredClone(modelCompose),
|
||||
"chat-app",
|
||||
false,
|
||||
);
|
||||
|
||||
expect(updated.models?.llm?.model).toBe("ai/smollm2");
|
||||
expect(updated.services?.app?.models).toEqual(["llm"]);
|
||||
expect(updated.services?.worker?.models).toEqual({
|
||||
llm: {
|
||||
endpoint_var: "LLM_URL",
|
||||
model_var: "LLM_MODEL",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("raw remote compose conversion preserves models", async () => {
|
||||
const converted = await addDomainToCompose(
|
||||
{
|
||||
appName: "chat-app",
|
||||
composeFile: stringify(modelCompose, { lineWidth: 1000 }),
|
||||
composePath: "./docker-compose.yml",
|
||||
composeType: "docker-compose",
|
||||
isolatedDeployment: false,
|
||||
isolatedDeploymentsVolume: false,
|
||||
randomize: false,
|
||||
serverId: "remote-server",
|
||||
sourceType: "raw",
|
||||
suffix: "",
|
||||
} as unknown as Compose,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(execAsyncRemote).not.toHaveBeenCalled();
|
||||
expect(converted?.models?.llm?.model).toBe("ai/smollm2");
|
||||
expect(converted?.services?.app?.models).toEqual(["llm"]);
|
||||
expect(converted?.services?.worker?.models).toEqual({
|
||||
llm: {
|
||||
endpoint_var: "LLM_URL",
|
||||
model_var: "LLM_MODEL",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockUpdateCompose = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@dokploy/server/services/permission", () => ({
|
||||
checkServicePermissionAndAccess: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server", () => ({
|
||||
IS_CLOUD: true,
|
||||
findComposeById: vi.fn(async () => ({
|
||||
composeId: "compose-1",
|
||||
name: "example-compose",
|
||||
})),
|
||||
updateCompose: mockUpdateCompose,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/lib/auth", () => ({
|
||||
validateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/api/utils/audit", () => ({
|
||||
audit: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/queues/queueSetup", () => ({
|
||||
cleanQueuesByCompose: vi.fn(),
|
||||
killDockerBuild: vi.fn(),
|
||||
myQueue: {
|
||||
add: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { composeRouter } = await import("@/server/api/routers/compose");
|
||||
|
||||
const caller = composeRouter.createCaller({
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-1", email: "user@example.com", role: "owner" },
|
||||
} as Parameters<typeof composeRouter.createCaller>[0]);
|
||||
|
||||
describe("saving a compose provider", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each(["done", "running"])(
|
||||
"keeps the %s status when disconnecting a Git provider",
|
||||
async (status) => {
|
||||
let savedStatus = status;
|
||||
mockUpdateCompose.mockImplementation(async (_id, update) => {
|
||||
if (update.composeStatus !== undefined) {
|
||||
savedStatus = update.composeStatus;
|
||||
}
|
||||
});
|
||||
|
||||
await caller.disconnectGitProvider({ composeId: "compose-1" });
|
||||
|
||||
expect(savedStatus).toBe(status);
|
||||
expect(mockUpdateCompose).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateCompose).toHaveBeenCalledWith(
|
||||
"compose-1",
|
||||
expect.objectContaining({ sourceType: "github" }),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -87,224 +87,6 @@ describe("cloudflareClient.listRecords", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient MX priority", () => {
|
||||
it("inlines the priority into the content when listing", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
cfSuccess([
|
||||
{
|
||||
id: "mx-1",
|
||||
type: "MX",
|
||||
name: "example.com",
|
||||
content: "mail.example.com",
|
||||
ttl: 300,
|
||||
priority: 20,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await cloudflareClient.listRecords(config, "zone-1");
|
||||
|
||||
expect(records[0]?.content).toBe("20 mail.example.com");
|
||||
});
|
||||
|
||||
it("splits the priority back out when writing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "MX",
|
||||
name: "example.com",
|
||||
content: "20 mail.example.com",
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(JSON.parse(init.body as string)).toMatchObject({
|
||||
content: "mail.example.com",
|
||||
priority: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to priority 10 when the content has no leading number", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "MX",
|
||||
name: "example.com",
|
||||
content: "mail.example.com",
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(JSON.parse(init.body as string)).toMatchObject({
|
||||
content: "mail.example.com",
|
||||
priority: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves non-MX content untouched", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "TXT",
|
||||
name: "example.com",
|
||||
content: "10 not-a-priority",
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.content).toBe("10 not-a-priority");
|
||||
expect(body.priority).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient structured records", () => {
|
||||
const stubCreate = () =>
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "rec-1" }));
|
||||
|
||||
it("sends SRV values as structured data", async () => {
|
||||
stubCreate();
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "SRV",
|
||||
name: "_sip._tcp.example.com",
|
||||
content: "1 10 5269 talk.example.com",
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.data).toEqual({
|
||||
priority: 1,
|
||||
weight: 10,
|
||||
port: 5269,
|
||||
target: "talk.example.com",
|
||||
});
|
||||
expect(body.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends CAA values as structured data and drops the quotes", async () => {
|
||||
stubCreate();
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "CAA",
|
||||
name: "example.com",
|
||||
content: '0 issue "letsencrypt.org"',
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.data).toEqual({
|
||||
flags: 0,
|
||||
tag: "issue",
|
||||
value: "letsencrypt.org",
|
||||
});
|
||||
expect(body.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects an SRV value that is missing a part", async () => {
|
||||
await expect(
|
||||
cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "SRV",
|
||||
name: "_sip._tcp.example.com",
|
||||
content: "1 10 5269",
|
||||
}),
|
||||
).rejects.toThrow(/SRV value/);
|
||||
});
|
||||
|
||||
it("rejects a CAA value that has no tag", async () => {
|
||||
await expect(
|
||||
cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "CAA",
|
||||
name: "example.com",
|
||||
content: "0",
|
||||
}),
|
||||
).rejects.toThrow(/CAA value/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient proxy status", () => {
|
||||
it("sends the proxy status for proxiable types", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
proxied: true,
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(JSON.parse(init.body as string).proxied).toBe(true);
|
||||
});
|
||||
|
||||
it("omits the proxy status for types Cloudflare cannot proxy", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "TXT",
|
||||
name: "example.com",
|
||||
content: "hello",
|
||||
proxied: true,
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(JSON.parse(init.body as string).proxied).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves the proxy status untouched when the caller does not set it", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect("proxied" in JSON.parse(init.body as string)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns the proxy status when listing records", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
cfSuccess([
|
||||
{
|
||||
id: "a-1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 1,
|
||||
proxied: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await cloudflareClient.listRecords(config, "zone-1");
|
||||
|
||||
expect(records[0]?.proxied).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient.upsertRecord", () => {
|
||||
it("creates a record when none exists for the name/type", async () => {
|
||||
mockFetch
|
||||
@@ -323,11 +105,9 @@ describe("cloudflareClient.upsertRecord", () => {
|
||||
expect(createInit.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("updates the existing record when content matches", async () => {
|
||||
it("updates the existing record instead of creating a duplicate", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
cfSuccess([{ id: "existing-1", type: "A", content: "5.6.7.8" }]),
|
||||
)
|
||||
.mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "existing-1" }));
|
||||
|
||||
const result = await cloudflareClient.upsertRecord(config, {
|
||||
@@ -346,25 +126,6 @@ describe("cloudflareClient.upsertRecord", () => {
|
||||
expect(updateInit.method).toBe("PUT");
|
||||
});
|
||||
|
||||
it("creates a new record when content differs from existing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
cfSuccess([{ id: "existing-1", type: "A", content: "1.1.1.1" }]),
|
||||
)
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "new-2" }));
|
||||
|
||||
const result = await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "5.6.7.8",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "new-2" });
|
||||
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(createInit.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("defaults ttl to 1 (automatic) when not provided", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
|
||||
@@ -1,486 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as typeof fetch;
|
||||
|
||||
import { infomaniakClient } from "@dokploy/server/utils/dns/infomaniak";
|
||||
|
||||
const jsonResponse = (body: unknown, ok = true, status = 200) =>
|
||||
({
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
}) as Response;
|
||||
|
||||
const ikSuccess = (data: unknown) => jsonResponse({ result: "success", data });
|
||||
|
||||
const ikPage = (data: unknown, page: number, pages: number) =>
|
||||
jsonResponse({ result: "success", data, page, pages });
|
||||
|
||||
const ikError = (description: string, status = 400) =>
|
||||
jsonResponse(
|
||||
{ result: "error", error: { code: "not_authorized", description } },
|
||||
false,
|
||||
status,
|
||||
);
|
||||
|
||||
const config = {
|
||||
providerType: "infomaniak" as const,
|
||||
apiToken: "ik_test_token",
|
||||
};
|
||||
|
||||
const lastCall = () =>
|
||||
mockFetch.mock.calls.at(-1) as [string, RequestInit & { method?: string }];
|
||||
|
||||
const lastBody = () => JSON.parse(lastCall()[1].body as string);
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("infomaniakClient.listZones", () => {
|
||||
it("exposes each domain product as a zone keyed by its name", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
ikPage(
|
||||
[
|
||||
{ id: 1, customer_name: "example.com" },
|
||||
{ id: 2, customer_name: "example.ch" },
|
||||
],
|
||||
1,
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
const zones = await infomaniakClient.listZones(config);
|
||||
|
||||
expect(zones).toEqual([
|
||||
{ id: "example.com", name: "example.com" },
|
||||
{ id: "example.ch", name: "example.ch" },
|
||||
]);
|
||||
const [url, init] = lastCall();
|
||||
// The documented endpoint is the plural one; the singular is legacy and
|
||||
// returns no pagination metadata at all.
|
||||
expect(url).toContain("/1/products?service_name=domain");
|
||||
expect(url).toContain("page=1");
|
||||
expect(init.headers).toMatchObject({
|
||||
Authorization: "Bearer ik_test_token",
|
||||
});
|
||||
});
|
||||
|
||||
it("walks every page so accounts with many domains keep all their zones", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikPage([{ id: 1, customer_name: "a.com" }], 1, 3))
|
||||
.mockResolvedValueOnce(ikPage([{ id: 2, customer_name: "b.com" }], 2, 3))
|
||||
.mockResolvedValueOnce(ikPage([{ id: 3, customer_name: "c.com" }], 3, 3));
|
||||
|
||||
const zones = await infomaniakClient.listZones(config);
|
||||
|
||||
expect(zones.map((zone) => zone.name)).toEqual(["a.com", "b.com", "c.com"]);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
expect((mockFetch.mock.calls[2] as [string])[0]).toContain("page=3");
|
||||
});
|
||||
|
||||
it("stops after a single page when the response has no pagination", async () => {
|
||||
mockFetch.mockResolvedValue(ikSuccess([{ id: 1, customer_name: "a.com" }]));
|
||||
|
||||
const zones = await infomaniakClient.listZones(config);
|
||||
|
||||
expect(zones).toEqual([{ id: "a.com", name: "a.com" }]);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("propagates the API error description", async () => {
|
||||
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
|
||||
|
||||
await expect(infomaniakClient.listZones(config)).rejects.toThrow(
|
||||
"Authorization required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("infomaniakClient.listRecords", () => {
|
||||
it("rebuilds the fqdn from the relative source", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
ikSuccess([
|
||||
{ id: 10, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
|
||||
{ id: 11, type: "A", source: "", target: "5.6.7.8", ttl: 600 },
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
id: "10",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 300,
|
||||
},
|
||||
{
|
||||
id: "11",
|
||||
type: "A",
|
||||
name: "example.com",
|
||||
content: "5.6.7.8",
|
||||
ttl: 600,
|
||||
},
|
||||
]);
|
||||
expect(lastCall()[0]).toBe(
|
||||
"https://api.infomaniak.com/2/zones/example.com/records?with=records_description",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([".", "", "@"])("treats a %s source as the apex", async (source) => {
|
||||
mockFetch.mockResolvedValue(
|
||||
ikSuccess([{ id: 12, type: "A", source, target: "1.2.3.4", ttl: 300 }]),
|
||||
);
|
||||
|
||||
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||
|
||||
expect(records[0]?.name).toBe("example.com");
|
||||
});
|
||||
|
||||
it("unquotes TXT targets", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
ikSuccess([
|
||||
{
|
||||
id: 13,
|
||||
type: "TXT",
|
||||
source: "_acme-challenge",
|
||||
target: '"token-value"',
|
||||
ttl: 300,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||
|
||||
expect(records[0]?.content).toBe("token-value");
|
||||
});
|
||||
|
||||
it("leaves a CAA target untouched", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
ikSuccess([
|
||||
{
|
||||
id: 14,
|
||||
type: "CAA",
|
||||
source: "",
|
||||
target: '0 issue "letsencrypt.org"',
|
||||
ttl: 300,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||
|
||||
expect(records[0]?.content).toBe('0 issue "letsencrypt.org"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("infomaniakClient.upsertRecord", () => {
|
||||
it("creates the record when no matching source and type exists", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikSuccess([]))
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 42 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 600,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "42" });
|
||||
const [url, init] = lastCall();
|
||||
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(lastBody()).toEqual({
|
||||
type: "A",
|
||||
source: "app",
|
||||
target: "1.2.3.4",
|
||||
ttl: 600,
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the existing record when content matches", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{ id: 7, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 7 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "7" });
|
||||
const [url, init] = lastCall();
|
||||
expect(url).toBe(
|
||||
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
||||
);
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(lastBody().ttl).toBe(300);
|
||||
});
|
||||
|
||||
it("creates a new record when content differs from existing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{ id: 7, type: "A", source: "app", target: "1.1.1.1", ttl: 300 },
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 50 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "50" });
|
||||
const [url, init] = lastCall();
|
||||
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
|
||||
expect(init.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("writes a root dot as the source for an apex record and strips the trailing dot", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikSuccess([]))
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 43 }));
|
||||
|
||||
await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "example.com.",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(lastBody().source).toBe(".");
|
||||
});
|
||||
|
||||
it.each([".", "", "@"])(
|
||||
"matches an existing apex record stored with a %s source",
|
||||
async (source) => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{ id: 8, type: "A", source, target: "1.2.3.4", ttl: 3600 },
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "8" });
|
||||
expect(lastCall()[1].method).toBe("PUT");
|
||||
},
|
||||
);
|
||||
|
||||
it("matches the existing apex record instead of creating a duplicate", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{
|
||||
id: 8,
|
||||
type: "TXT",
|
||||
source: ".",
|
||||
target: '"v=spf1 -all"',
|
||||
ttl: 3600,
|
||||
},
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "TXT",
|
||||
name: "example.com",
|
||||
content: "v=spf1 -all",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "8" });
|
||||
const [url, init] = lastCall();
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(url).toBe(
|
||||
"https://api.infomaniak.com/2/zones/example.com/records/8",
|
||||
);
|
||||
expect(lastBody().target).toBe('"v=spf1 -all"');
|
||||
});
|
||||
|
||||
it("quotes a TXT target on write", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikSuccess([]))
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 44 }));
|
||||
|
||||
await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "TXT",
|
||||
name: "_acme-challenge.example.com",
|
||||
content: "token-value",
|
||||
});
|
||||
|
||||
expect(lastBody().target).toBe('"token-value"');
|
||||
});
|
||||
|
||||
it("does not double-quote a TXT target that is already quoted", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikSuccess([]))
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 45 }));
|
||||
|
||||
await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "TXT",
|
||||
name: "_acme-challenge.example.com",
|
||||
content: '"token-value"',
|
||||
});
|
||||
|
||||
expect(lastBody().target).toBe('"token-value"');
|
||||
});
|
||||
|
||||
it("queries the API with a source and type filter instead of the whole zone", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikSuccess([]))
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 50 }));
|
||||
|
||||
await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
const [url] = mockFetch.mock.calls[0] as [string];
|
||||
expect(url).toContain("filter%5Bsource%5D=app");
|
||||
expect(url).toContain("filter%5Btypes%5D%5B%5D=A");
|
||||
});
|
||||
|
||||
it("ignores a partial filter hit rather than overwriting a different record", async () => {
|
||||
// filter[source] matches substrings: asking for "auto" also returns
|
||||
// "autoconfig" and "autodiscover". Trusting it would overwrite one of them.
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{
|
||||
id: 61,
|
||||
type: "CNAME",
|
||||
source: "autoconfig",
|
||||
target: "a.example.net",
|
||||
ttl: 300,
|
||||
},
|
||||
{
|
||||
id: 62,
|
||||
type: "CNAME",
|
||||
source: "autodiscover",
|
||||
target: "b.example.net",
|
||||
ttl: 300,
|
||||
},
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 63 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "CNAME",
|
||||
name: "auto.example.com",
|
||||
content: "c.example.net",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "63" });
|
||||
expect(lastCall()[1].method).toBe("POST");
|
||||
});
|
||||
});
|
||||
|
||||
describe("infomaniakClient.updateRecord", () => {
|
||||
it("updates the record and keeps its id", async () => {
|
||||
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
|
||||
|
||||
const result = await infomaniakClient.updateRecord(
|
||||
config,
|
||||
"example.com",
|
||||
"7",
|
||||
{
|
||||
type: "CNAME",
|
||||
name: "www.example.com",
|
||||
content: "example.com",
|
||||
ttl: 900,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ id: "7" });
|
||||
const [url, init] = lastCall();
|
||||
expect(url).toBe(
|
||||
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
||||
);
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(lastBody()).toEqual({
|
||||
type: "CNAME",
|
||||
source: "www",
|
||||
target: "example.com",
|
||||
ttl: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the default ttl when none is provided", async () => {
|
||||
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
|
||||
|
||||
await infomaniakClient.updateRecord(config, "example.com", "7", {
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(lastBody().ttl).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe("infomaniakClient.deleteRecord", () => {
|
||||
it("deletes the record", async () => {
|
||||
mockFetch.mockResolvedValue(ikSuccess(null));
|
||||
|
||||
await infomaniakClient.deleteRecord(config, "example.com", "7");
|
||||
|
||||
const [url, init] = lastCall();
|
||||
expect(url).toBe(
|
||||
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
||||
);
|
||||
expect(init.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
it("propagates a delete failure", async () => {
|
||||
mockFetch.mockResolvedValue(ikError("Record not found", 404));
|
||||
|
||||
await expect(
|
||||
infomaniakClient.deleteRecord(config, "example.com", "7"),
|
||||
).rejects.toThrow("Record not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("infomaniakClient.testConnection", () => {
|
||||
it("resolves when the domain listing succeeds", async () => {
|
||||
mockFetch.mockResolvedValue(ikSuccess([]));
|
||||
|
||||
await expect(
|
||||
infomaniakClient.testConnection(config),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects on an invalid token", async () => {
|
||||
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
|
||||
|
||||
await expect(infomaniakClient.testConnection(config)).rejects.toThrow(
|
||||
"Infomaniak: request to /1/products?service_name=domain&per_page=1 failed: Authorization required",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,642 +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");
|
||||
});
|
||||
|
||||
it("returns only the configured zone without listing every zone", async () => {
|
||||
const cfg = { ...freshConfig(), zone: "example.com" };
|
||||
mockApi();
|
||||
|
||||
const zones = await ovhClient.listZones(cfg);
|
||||
|
||||
expect(zones).toEqual([{ id: "example.com", name: "example.com" }]);
|
||||
expect(apiCalls()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
|
||||
it("checks the configured zone instead of listing all zones", async () => {
|
||||
const cfg = { ...freshConfig(), zone: "example.com" };
|
||||
mockApi(ovhSuccess({ name: "example.com" }));
|
||||
|
||||
await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
|
||||
|
||||
const calls = apiCalls();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.[0]).toBe(`${cfg.baseUrl}/domain/zone/example.com`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient with a configured zone", () => {
|
||||
const record = {
|
||||
type: "A" as const,
|
||||
name: "app.other.org",
|
||||
content: "1.2.3.4",
|
||||
};
|
||||
|
||||
it("refuses record operations on any other zone without calling OVH", async () => {
|
||||
const cfg = { ...freshConfig(), zone: "example.com" };
|
||||
mockApi();
|
||||
|
||||
const message = /restricted to the zone "example.com"/;
|
||||
await expect(ovhClient.listRecords(cfg, "other.org")).rejects.toThrow(
|
||||
message,
|
||||
);
|
||||
await expect(
|
||||
ovhClient.upsertRecord(cfg, { ...record, zoneId: "other.org" }),
|
||||
).rejects.toThrow(message);
|
||||
await expect(
|
||||
ovhClient.updateRecord(cfg, "other.org", "1", record),
|
||||
).rejects.toThrow(message);
|
||||
await expect(ovhClient.deleteRecord(cfg, "other.org", "1")).rejects.toThrow(
|
||||
message,
|
||||
);
|
||||
expect(apiCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still allows operations on the configured zone", async () => {
|
||||
const cfg = { ...freshConfig(), zone: "example.com" };
|
||||
mockApi(ovhSuccess(null), ovhSuccess(null));
|
||||
|
||||
await expect(
|
||||
ovhClient.deleteRecord(cfg, "example.com", "1"),
|
||||
).resolves.toBeUndefined();
|
||||
expect(apiCalls()[0]?.[0]).toBe(
|
||||
`${cfg.baseUrl}/domain/zone/example.com/record/1`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,237 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as typeof fetch;
|
||||
|
||||
import { porkbunClient } from "@dokploy/server/utils/dns/porkbun";
|
||||
|
||||
const jsonResponse = (body: unknown, ok = true, status = 200) =>
|
||||
({
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
}) as Response;
|
||||
|
||||
const pbSuccess = (result: Record<string, unknown> = {}) =>
|
||||
jsonResponse({ status: "SUCCESS", ...result });
|
||||
|
||||
const pbError = (message: string, status = 400) =>
|
||||
jsonResponse({ status: "ERROR", message }, false, status);
|
||||
|
||||
const config = {
|
||||
providerType: "porkbun" as const,
|
||||
apiKey: "pk1_test",
|
||||
secretApiKey: "sk1_test",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("porkbunClient.listZones", () => {
|
||||
it("lists all domains as zones", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
pbSuccess({ domains: [{ domain: "example.com" }] }),
|
||||
);
|
||||
|
||||
const zones = await porkbunClient.listZones(config);
|
||||
|
||||
expect(zones).toEqual([{ id: "example.com", name: "example.com" }]);
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain("/domain/listAll");
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body).toMatchObject({
|
||||
apikey: "pk1_test",
|
||||
secretapikey: "sk1_test",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("porkbunClient.listRecords", () => {
|
||||
it("lists records for a domain", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
pbSuccess({
|
||||
records: [
|
||||
{
|
||||
id: "1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: "600",
|
||||
prio: "0",
|
||||
notes: "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const records = await porkbunClient.listRecords(config, "example.com");
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
id: "1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 600,
|
||||
},
|
||||
]);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toContain("/dns/retrieve/example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("porkbunClient.upsertRecord", () => {
|
||||
it("creates a record when none exists for the name/type", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(pbSuccess({ records: [] }))
|
||||
.mockResolvedValueOnce(pbSuccess({ id: "new-1" }));
|
||||
|
||||
const result = await porkbunClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "new-1" });
|
||||
const [lookupUrl] = mockFetch.mock.calls[0] as [string];
|
||||
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/app");
|
||||
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: "1.2.3.4" });
|
||||
});
|
||||
|
||||
it("resolves the apex domain to an empty subdomain", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(pbSuccess({ records: [] }))
|
||||
.mockResolvedValueOnce(pbSuccess({ id: "new-2" }));
|
||||
|
||||
await porkbunClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
const [lookupUrl] = mockFetch.mock.calls[0] as [string];
|
||||
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/");
|
||||
});
|
||||
|
||||
it("edits the existing record when content matches", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
pbSuccess({ records: [{ id: "existing-1", content: "5.6.7.8" }] }),
|
||||
)
|
||||
.mockResolvedValueOnce(pbSuccess({}));
|
||||
|
||||
const result = await porkbunClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "5.6.7.8",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "existing-1" });
|
||||
const [editUrl] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(editUrl).toContain("/dns/edit/example.com/existing-1");
|
||||
});
|
||||
|
||||
it("creates a new record when content differs from existing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
pbSuccess({ records: [{ id: "existing-1", content: "1.1.1.1" }] }),
|
||||
)
|
||||
.mockResolvedValueOnce(pbSuccess({ id: "new-2" }));
|
||||
|
||||
const result = await porkbunClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "5.6.7.8",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "new-2" });
|
||||
const [createUrl, createInit] = mockFetch.mock.calls[1] as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(createUrl).toContain("/dns/create/example.com");
|
||||
const body = JSON.parse(createInit.body as string);
|
||||
expect(body).toMatchObject({ name: "app", type: "A", content: "5.6.7.8" });
|
||||
});
|
||||
|
||||
it("defaults ttl to 600 when not provided", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(pbSuccess({ records: [] }))
|
||||
.mockResolvedValueOnce(pbSuccess({ id: "new-1" }));
|
||||
|
||||
await porkbunClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "CNAME",
|
||||
name: "www.example.com",
|
||||
content: "example.com",
|
||||
});
|
||||
|
||||
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
const body = JSON.parse(createInit.body as string);
|
||||
expect(body.ttl).toBe(600);
|
||||
});
|
||||
});
|
||||
|
||||
describe("porkbunClient.updateRecord", () => {
|
||||
it("edits the given record id", async () => {
|
||||
mockFetch.mockResolvedValue(pbSuccess({}));
|
||||
|
||||
const result = await porkbunClient.updateRecord(
|
||||
config,
|
||||
"example.com",
|
||||
"1",
|
||||
{
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "9.9.9.9",
|
||||
ttl: 300,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ id: "1" });
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain("/dns/edit/example.com/1");
|
||||
expect(JSON.parse(init.body as string)).toMatchObject({
|
||||
name: "app",
|
||||
type: "A",
|
||||
content: "9.9.9.9",
|
||||
ttl: 300,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("porkbunClient.deleteRecord", () => {
|
||||
it("posts to the delete endpoint for the given record id", async () => {
|
||||
mockFetch.mockResolvedValue(pbSuccess({}));
|
||||
|
||||
await porkbunClient.deleteRecord(config, "example.com", "1");
|
||||
|
||||
const [url] = mockFetch.mock.calls[0] as [string];
|
||||
expect(url).toContain("/dns/delete/example.com/1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("porkbunClient.testConnection", () => {
|
||||
it("succeeds when the credentials can ping the API", async () => {
|
||||
mockFetch.mockResolvedValue(pbSuccess({}));
|
||||
await expect(porkbunClient.testConnection(config)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces Porkbun's error message on invalid credentials", async () => {
|
||||
mockFetch.mockResolvedValue(pbError("Invalid API key."));
|
||||
|
||||
await expect(porkbunClient.testConnection(config)).rejects.toThrow(
|
||||
"Invalid API key.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -142,15 +142,13 @@ describe("route53Client.listRecords", () => {
|
||||
|
||||
const records = await route53Client.listRecords(config, "Z123");
|
||||
|
||||
expect(records[0]?.content).toBe("ns1.example.com\nns2.example.com");
|
||||
expect(records[0]?.content).toBe("ns1.example.com, ns2.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.upsertRecord", () => {
|
||||
it("sends a single UPSERT change when nothing exists yet", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({ ResourceRecordSets: [] })
|
||||
.mockResolvedValueOnce({});
|
||||
it("sends a single UPSERT change", async () => {
|
||||
send.mockResolvedValueOnce({});
|
||||
|
||||
const result = await route53Client.upsertRecord(config, {
|
||||
zoneId: "Z123",
|
||||
@@ -160,7 +158,7 @@ describe("route53Client.upsertRecord", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "A:app.example.com" });
|
||||
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||
const command = send.mock.calls[0]?.[0] as HasInput;
|
||||
expect(command.input.HostedZoneId).toBe("Z123");
|
||||
expect(command.input.ChangeBatch.Changes).toEqual([
|
||||
{
|
||||
@@ -174,82 +172,6 @@ describe("route53Client.upsertRecord", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the values already in the record set", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{
|
||||
Name: "app.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.1.1.1" }, { Value: "2.2.2.2" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.upsertRecord(config, {
|
||||
zoneId: "Z123",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "3.3.3.3",
|
||||
});
|
||||
|
||||
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(
|
||||
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
|
||||
).toEqual([
|
||||
{ Value: "1.1.1.1" },
|
||||
{ Value: "2.2.2.2" },
|
||||
{ Value: "3.3.3.3" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not duplicate a value that is already in the record set", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{
|
||||
Name: "app.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.1.1.1" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.upsertRecord(config, {
|
||||
zoneId: "Z123",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.1.1.1",
|
||||
});
|
||||
|
||||
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(
|
||||
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
|
||||
).toEqual([{ Value: "1.1.1.1" }]);
|
||||
});
|
||||
|
||||
it("wraps unquoted TXT values in double quotes", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({ ResourceRecordSets: [] })
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.upsertRecord(config, {
|
||||
zoneId: "Z123",
|
||||
type: "TXT",
|
||||
name: "example.com",
|
||||
content: 'v=spf1 ~all\n"already quoted"',
|
||||
});
|
||||
|
||||
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(
|
||||
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
|
||||
).toEqual([{ Value: '"v=spf1 ~all"' }, { Value: '"already quoted"' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.updateRecord", () => {
|
||||
@@ -300,25 +222,6 @@ describe("route53Client.updateRecord", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps every line of a multi-value record set", async () => {
|
||||
send.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.updateRecord(config, "Z123", "NS:example.com", {
|
||||
type: "NS",
|
||||
name: "example.com",
|
||||
content: "ns1.example.com\nns2.example.com\n\n ns3.example.com ",
|
||||
});
|
||||
|
||||
const command = send.mock.calls[0]?.[0] as HasInput;
|
||||
expect(
|
||||
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
|
||||
).toEqual([
|
||||
{ Value: "ns1.example.com" },
|
||||
{ Value: "ns2.example.com" },
|
||||
{ Value: "ns3.example.com" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips the DELETE when the old record no longer exists", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({ ResourceRecordSets: [] })
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
-345
@@ -20,8 +20,6 @@ import {
|
||||
import { azureClient } from "@dokploy/server/utils/vault/azure";
|
||||
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
|
||||
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
|
||||
import { infisicalClient } from "@dokploy/server/utils/vault/infisical";
|
||||
import { phaseClient } from "@dokploy/server/utils/vault/phase";
|
||||
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
@@ -432,223 +430,6 @@ describe("azure client", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("infisical client", () => {
|
||||
const config = {
|
||||
providerType: "infisical" as const,
|
||||
siteUrl: "https://app.infisical.com",
|
||||
clientId: "client-1",
|
||||
clientSecret: "client-secret",
|
||||
projectId: "workspace-1",
|
||||
environmentSlug: "prod",
|
||||
secretPath: "/frontend",
|
||||
};
|
||||
|
||||
const loginResponse = () => jsonResponse({ accessToken: "token-1" });
|
||||
const list = (secrets: Record<string, string>) =>
|
||||
jsonResponse({
|
||||
secrets: Object.entries(secrets).map(([secretKey, secretValue]) => ({
|
||||
secretKey,
|
||||
secretValue,
|
||||
})),
|
||||
});
|
||||
const listPathOf = (callIndex: number) => {
|
||||
const [url] = mockFetch.mock.calls[callIndex] as [string];
|
||||
return new URL(url).searchParams.get("secretPath");
|
||||
};
|
||||
|
||||
it("asks the list endpoint to expand secret references", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({ DB_URL: "postgres://real" }));
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||
|
||||
expect(result).toEqual({ DB_URL: "postgres://real" });
|
||||
const [listUrl] = mockFetch.mock.calls[1] as [string];
|
||||
const params = new URL(listUrl).searchParams;
|
||||
expect(params.get("expandSecretReferences")).toBe("true");
|
||||
expect(params.get("workspaceId")).toBe("workspace-1");
|
||||
expect(params.get("environment")).toBe("prod");
|
||||
expect(params.get("secretPath")).toBe("/frontend");
|
||||
});
|
||||
|
||||
it("asks for imported secrets and merges them", async () => {
|
||||
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
secrets: [],
|
||||
imports: [
|
||||
{
|
||||
secretPath: "/shared",
|
||||
secrets: [
|
||||
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||
|
||||
expect(result).toEqual({ DB_URL: "postgres://imported" });
|
||||
const [listUrl] = mockFetch.mock.calls[1] as [string];
|
||||
// snake_case on purpose: `includeImports` is silently ignored and the
|
||||
// response comes back with an empty `imports` array.
|
||||
expect(new URL(listUrl).searchParams.get("include_imports")).toBe("true");
|
||||
});
|
||||
|
||||
it("lets the later import win when two define the same key", async () => {
|
||||
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
secrets: [],
|
||||
imports: [
|
||||
{
|
||||
secretPath: "/base",
|
||||
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://base" }],
|
||||
},
|
||||
{
|
||||
secretPath: "/override",
|
||||
secrets: [
|
||||
{ secretKey: "DB_URL", secretValue: "postgres://override" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||
|
||||
// Infisical documents this order: "If two imports carry a secret with the
|
||||
// same name, the value from the bottom-most import wins." The response
|
||||
// lists imports in that order, so assigning them in sequence matches it.
|
||||
expect(result).toEqual({ DB_URL: "postgres://override" });
|
||||
});
|
||||
|
||||
it("lets a folder's own secret win over an imported one of the same name", async () => {
|
||||
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://local" }],
|
||||
imports: [
|
||||
{
|
||||
secretPath: "/shared",
|
||||
secrets: [
|
||||
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||
|
||||
expect(result).toEqual({ DB_URL: "postgres://local" });
|
||||
});
|
||||
|
||||
it("throws a clear error for a missing secret", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(jsonResponse({ secrets: [] }));
|
||||
|
||||
await expect(
|
||||
infisicalClient.getSecrets(config, ["ABSENT"]),
|
||||
).rejects.toThrow('secret "ABSENT" not found in environment "prod"');
|
||||
});
|
||||
|
||||
it("propagates authentication failures with the status code", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({}, false, 401));
|
||||
|
||||
await expect(
|
||||
infisicalClient.getSecrets(config, ["DB_URL"]),
|
||||
).rejects.toThrow("authentication failed (status 401)");
|
||||
});
|
||||
|
||||
it("resolves a relative <path>:<KEY> ref against the provider path", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, [
|
||||
"shared/sentry:SENTRY_DSN",
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
"shared/sentry:SENTRY_DSN": "https://key@sentry.io/1",
|
||||
});
|
||||
expect(listPathOf(1)).toBe("/frontend/shared/sentry");
|
||||
});
|
||||
|
||||
it("treats a leading slash as an absolute path", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
|
||||
|
||||
await infisicalClient.getSecrets(config, ["/external/sentry:SENTRY_DSN"]);
|
||||
|
||||
expect(listPathOf(1)).toBe("/external/sentry");
|
||||
});
|
||||
|
||||
it("keeps the root path clean when the provider sits at /", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({ KEY: "value" }));
|
||||
|
||||
await infisicalClient.getSecrets({ ...config, secretPath: "/" }, [
|
||||
"external/sentry:KEY",
|
||||
]);
|
||||
|
||||
expect(listPathOf(1)).toBe("/external/sentry");
|
||||
});
|
||||
|
||||
it("logs in once and fetches each path once", async () => {
|
||||
const byPath: Record<string, Record<string, string>> = {
|
||||
"/frontend": { A: "a", B: "b" },
|
||||
"/frontend/other": { C: "c" },
|
||||
};
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("/auth/universal-auth/login")) return loginResponse();
|
||||
const path = new URL(url).searchParams.get("secretPath") as string;
|
||||
return list(byPath[path] ?? {});
|
||||
});
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, [
|
||||
"A",
|
||||
"B",
|
||||
"other:C",
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ A: "a", B: "b", "other:C": "c" });
|
||||
|
||||
const urls = mockFetch.mock.calls.map(([url]) => url as string);
|
||||
expect(urls.filter((u) => u.includes("/login"))).toHaveLength(1);
|
||||
expect(
|
||||
urls
|
||||
.filter((u) => u.includes("/secrets/raw?"))
|
||||
.map((u) => new URL(u).searchParams.get("secretPath"))
|
||||
.sort(),
|
||||
).toEqual(["/frontend", "/frontend/other"]);
|
||||
});
|
||||
|
||||
it("names the path when a secret is missing from an explicit one", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({}));
|
||||
|
||||
await expect(
|
||||
infisicalClient.getSecrets(config, ["external/sentry:ABSENT"]),
|
||||
).rejects.toThrow(
|
||||
'secret "ABSENT" not found at "/frontend/external/sentry"',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a ref with an empty path or key", async () => {
|
||||
await expect(infisicalClient.getSecrets(config, [":KEY"])).rejects.toThrow(
|
||||
"expected format <path>:<KEY>",
|
||||
);
|
||||
await expect(
|
||||
infisicalClient.getSecrets(config, ["external/sentry:"]),
|
||||
).rejects.toThrow("expected format <path>:<KEY>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("doppler client", () => {
|
||||
it("propagates auth errors with the status code", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
|
||||
@@ -835,129 +616,3 @@ describe("scaleway client", () => {
|
||||
expect(result).toBe("DB_PASSWORD=s3cret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase client", () => {
|
||||
const config = {
|
||||
providerType: "phase" as const,
|
||||
token: "phase-rest-token",
|
||||
appId: "app-123",
|
||||
env: "Production",
|
||||
path: "/",
|
||||
apiUrl: "https://api.phase.dev",
|
||||
};
|
||||
|
||||
it("fetches secrets by key and sends ServiceAccount auth", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse([
|
||||
{ key: "DB_URL", value: "postgres://real", path: "/" },
|
||||
{ key: "API_KEY", value: "key-123", path: "/" },
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await phaseClient.getSecrets(config, ["DB_URL", "API_KEY"]);
|
||||
|
||||
expect(result).toEqual({ DB_URL: "postgres://real", API_KEY: "key-123" });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(
|
||||
"https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F",
|
||||
);
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||
"Bearer ServiceAccount phase-rest-token",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when a requested secret is missing", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse([{ key: "DB_URL", value: "postgres://real", path: "/" }]),
|
||||
);
|
||||
|
||||
await expect(phaseClient.getSecrets(config, ["MISSING"])).rejects.toThrow(
|
||||
'secret "MISSING" not found in environment "Production"',
|
||||
);
|
||||
});
|
||||
|
||||
it("reports authentication failures with the status code", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse({ error: "unauthorized" }, false, 401),
|
||||
);
|
||||
|
||||
await expect(phaseClient.getSecrets(config, ["DB_URL"])).rejects.toThrow(
|
||||
"authentication failed (status 401: unauthorized)",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects apps without SSE enabled during testConnection", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
id: "app-123",
|
||||
name: "My App",
|
||||
sseEnabled: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(phaseClient.testConnection(config)).rejects.toThrow(
|
||||
"enable Server-side Encryption (SSE)",
|
||||
);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe(
|
||||
"https://api.phase.dev/v1/apps/app-123/",
|
||||
);
|
||||
});
|
||||
|
||||
it("tests connection against the app then secrets listing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
id: "app-123",
|
||||
name: "My App",
|
||||
sseEnabled: true,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse([]));
|
||||
|
||||
await phaseClient.testConnection(config);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe(
|
||||
"https://api.phase.dev/v1/apps/app-123/",
|
||||
);
|
||||
expect(mockFetch.mock.calls[1]?.[0]).toBe(
|
||||
"https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists secret names from the configured path", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse([
|
||||
{ key: "DB_URL", value: "x", path: "/" },
|
||||
{ key: "API_KEY", value: "y", path: "/" },
|
||||
]),
|
||||
);
|
||||
|
||||
const names = await phaseClient.listSecretNames?.(config);
|
||||
|
||||
expect(names).toEqual(["DB_URL", "API_KEY"]);
|
||||
});
|
||||
|
||||
it("resolves env refs end to end through a phase provider", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "phase-prod",
|
||||
providerType: "phase",
|
||||
config,
|
||||
assignments: assignedEverywhere,
|
||||
},
|
||||
]);
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse([{ key: "DB_PASSWORD", value: "s3cret", path: "/" }]),
|
||||
);
|
||||
|
||||
const result = await resolveVaultReferences(
|
||||
"DB_PASSWORD=${{vault.phase-prod.DB_PASSWORD}}",
|
||||
scope,
|
||||
);
|
||||
|
||||
expect(result).toBe("DB_PASSWORD=s3cret");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const store = vi.hoisted(() => ({
|
||||
provider: {} as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/gitlab", () => ({
|
||||
findGitlabById: vi.fn(async () => ({ ...store.provider })),
|
||||
updateGitlab: vi.fn(async (_id: string, values: Record<string, unknown>) => {
|
||||
store.provider = { ...store.provider, ...values };
|
||||
}),
|
||||
}));
|
||||
|
||||
const { refreshGitlabToken } = await import(
|
||||
"@dokploy/server/utils/providers/gitlab"
|
||||
);
|
||||
|
||||
const GITLAB_ID = "gitlab-1";
|
||||
|
||||
// Behaves like GitLab: a refresh returns a new pair and invalidates the refresh token it used
|
||||
const createGitlab = () => {
|
||||
let validRefreshToken = "refresh-1";
|
||||
let issued = 1;
|
||||
let failNext = false;
|
||||
|
||||
const fetch = vi.fn(async (_url: string, init: RequestInit) => {
|
||||
const body = new URLSearchParams(init.body as string);
|
||||
// Keeps the request open, so concurrent callers overlap as two deployments do
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
if (failNext) {
|
||||
failNext = false;
|
||||
return new Response(null, {
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
});
|
||||
}
|
||||
|
||||
if (body.get("refresh_token") !== validRefreshToken) {
|
||||
return new Response(null, { status: 400, statusText: "Bad Request" });
|
||||
}
|
||||
|
||||
issued++;
|
||||
validRefreshToken = `refresh-${issued}`;
|
||||
return Response.json({
|
||||
access_token: `access-${issued}`,
|
||||
refresh_token: validRefreshToken,
|
||||
expires_in: 7200,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
fetch,
|
||||
failNextRequest: () => {
|
||||
failNext = true;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe("refreshGitlabToken", () => {
|
||||
let gitlab: ReturnType<typeof createGitlab>;
|
||||
|
||||
beforeEach(() => {
|
||||
store.provider = {
|
||||
gitlabId: GITLAB_ID,
|
||||
gitlabUrl: "https://gitlab.example.com",
|
||||
gitlabInternalUrl: null,
|
||||
applicationId: "app-id",
|
||||
secret: "app-secret",
|
||||
accessToken: "access-1",
|
||||
refreshToken: "refresh-1",
|
||||
expiresAt: 0,
|
||||
};
|
||||
gitlab = createGitlab();
|
||||
vi.stubGlobal("fetch", gitlab.fetch);
|
||||
});
|
||||
|
||||
it("sends one refresh request when two callers find the token expired at the same time", async () => {
|
||||
await Promise.all([
|
||||
refreshGitlabToken(GITLAB_ID),
|
||||
refreshGitlabToken(GITLAB_ID),
|
||||
]);
|
||||
|
||||
expect(gitlab.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(store.provider.refreshToken).toBe("refresh-2");
|
||||
});
|
||||
|
||||
it("refreshes again after a failed refresh", async () => {
|
||||
gitlab.failNextRequest();
|
||||
|
||||
await expect(refreshGitlabToken(GITLAB_ID)).rejects.toThrow(
|
||||
"Failed to refresh token: Internal Server Error",
|
||||
);
|
||||
await refreshGitlabToken(GITLAB_ID);
|
||||
|
||||
expect(gitlab.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(store.provider.refreshToken).toBe("refresh-2");
|
||||
});
|
||||
});
|
||||
@@ -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,56 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findOwnedOrganizations: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
removeVectorAgents: vi.fn(),
|
||||
calls: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: { organization: { findMany: mocks.findOwnedOrganizations } },
|
||||
transaction: mocks.transaction,
|
||||
},
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/organization", () => ({
|
||||
removeVectorAgents: mocks.removeVectorAgents,
|
||||
}));
|
||||
|
||||
const { deleteUserAccountData } = await import(
|
||||
"@dokploy/server/services/account-deletion"
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.calls.length = 0;
|
||||
mocks.findOwnedOrganizations.mockReset();
|
||||
mocks.removeVectorAgents
|
||||
.mockReset()
|
||||
.mockImplementation(async (id: string) => mocks.calls.push(`agents:${id}`));
|
||||
mocks.transaction.mockReset().mockImplementation(async () => {
|
||||
mocks.calls.push("delete");
|
||||
return { email: "a@b.c", organizationsDeleted: 2 };
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteUserAccountData", () => {
|
||||
it("removes the Vector agents of every owned organization before deleting the user", async () => {
|
||||
mocks.findOwnedOrganizations.mockResolvedValue([
|
||||
{ id: "org-a" },
|
||||
{ id: "org-b" },
|
||||
]);
|
||||
|
||||
await deleteUserAccountData("user-1");
|
||||
|
||||
expect(mocks.calls).toEqual(["agents:org-a", "agents:org-b", "delete"]);
|
||||
});
|
||||
|
||||
it("deletes the user without touching agents when it owns no organization", async () => {
|
||||
mocks.findOwnedOrganizations.mockResolvedValue([]);
|
||||
|
||||
await deleteUserAccountData("user-1");
|
||||
|
||||
expect(mocks.removeVectorAgents).not.toHaveBeenCalled();
|
||||
expect(mocks.calls).toEqual(["delete"]);
|
||||
});
|
||||
});
|
||||
@@ -1,542 +0,0 @@
|
||||
import {
|
||||
getTelemetryProviderAdapter,
|
||||
telemetryProviderAdapters,
|
||||
} from "@dokploy/server/services/logs-and-metrics/providers/registry";
|
||||
import type { TelemetryProviderRuntimeConfig } from "@dokploy/server/services/logs-and-metrics/types";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const aws = vi.hoisted(() => ({
|
||||
describeLogGroups: vi.fn(),
|
||||
putMetricData: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:dns/promises", () => ({
|
||||
lookup: async () => [{ address: "93.184.216.34" }],
|
||||
}));
|
||||
vi.mock("@aws-sdk/client-cloudwatch-logs", () => ({
|
||||
CloudWatchLogsClient: class {
|
||||
send = aws.describeLogGroups;
|
||||
},
|
||||
DescribeLogGroupsCommand: class {
|
||||
constructor(public input: unknown) {}
|
||||
},
|
||||
}));
|
||||
vi.mock("@aws-sdk/client-cloudwatch", () => ({
|
||||
CloudWatchClient: class {
|
||||
send = aws.putMetricData;
|
||||
},
|
||||
PutMetricDataCommand: class {
|
||||
constructor(public input: unknown) {}
|
||||
},
|
||||
}));
|
||||
|
||||
const baseConfig: TelemetryProviderRuntimeConfig = {
|
||||
telemetryProviderId: "provider-1",
|
||||
name: "test",
|
||||
signals: ["metrics"],
|
||||
endpoint: null,
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
};
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
const respond = (status: number, body: unknown = "") =>
|
||||
new Response(
|
||||
status === 204
|
||||
? null
|
||||
: typeof body === "string"
|
||||
? body
|
||||
: JSON.stringify(body),
|
||||
{ status },
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
aws.describeLogGroups.mockReset();
|
||||
aws.putMetricData.mockReset();
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const calledUrls = () => fetchMock.mock.calls.map((call) => String(call[0]));
|
||||
|
||||
describe("registry", () => {
|
||||
it("resolves the nine provider types, each with a non-empty valid signal list", () => {
|
||||
const types = [
|
||||
"loki",
|
||||
"prometheus_remote_write",
|
||||
"new_relic",
|
||||
"influxdb",
|
||||
"datadog",
|
||||
"aws_cloudwatch",
|
||||
"splunk_hec",
|
||||
"elasticsearch",
|
||||
"betterstack",
|
||||
] as const;
|
||||
expect(Object.keys(telemetryProviderAdapters).sort()).toEqual(
|
||||
[...types].sort(),
|
||||
);
|
||||
for (const type of types) {
|
||||
const adapter = getTelemetryProviderAdapter(type);
|
||||
expect(adapter.type).toBe(type);
|
||||
expect(adapter.signals.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
adapter.signals.every((s) => s === "logs" || s === "metrics"),
|
||||
).toBe(true);
|
||||
}
|
||||
expect(telemetryProviderAdapters.loki.signals).toEqual(["logs"]);
|
||||
expect(telemetryProviderAdapters.prometheus_remote_write.signals).toEqual([
|
||||
"metrics",
|
||||
]);
|
||||
expect(telemetryProviderAdapters.datadog.signals).toEqual([
|
||||
"logs",
|
||||
"metrics",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prometheusRemoteWriteAdapter", () => {
|
||||
const adapter = telemetryProviderAdapters.prometheus_remote_write;
|
||||
const endpoint = "http://prom.example.com:9090/api/v1/write";
|
||||
|
||||
it("builds a sink without auth, with the healthcheck disabled", () => {
|
||||
const sink = adapter.toVectorSink(
|
||||
{ ...baseConfig, endpoint },
|
||||
"sink_1",
|
||||
"dokploy_metrics_scope",
|
||||
"metrics",
|
||||
);
|
||||
expect(sink).toMatchObject({
|
||||
type: "prometheus_remote_write",
|
||||
inputs: ["dokploy_metrics_scope"],
|
||||
endpoint,
|
||||
healthcheck: { enabled: false },
|
||||
});
|
||||
expect(sink.auth).toBeUndefined();
|
||||
expect(sink.tenant_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses basic auth with a username, bearer with a token alone, and sets tenant_id", () => {
|
||||
const basic = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint,
|
||||
apiKey: "pw",
|
||||
extraConfig: { username: "12345", tenantId: "team-a" },
|
||||
},
|
||||
"sink_1",
|
||||
"scope",
|
||||
"metrics",
|
||||
);
|
||||
expect(basic.auth).toEqual({
|
||||
strategy: "basic",
|
||||
user: "12345",
|
||||
password: "pw",
|
||||
});
|
||||
expect(basic.tenant_id).toBe("team-a");
|
||||
|
||||
const bearer = adapter.toVectorSink(
|
||||
{ ...baseConfig, endpoint, apiKey: "tok" },
|
||||
"sink_1",
|
||||
"scope",
|
||||
"metrics",
|
||||
);
|
||||
expect(bearer.auth).toEqual({ strategy: "bearer", token: "tok" });
|
||||
});
|
||||
|
||||
it("rejects an endpoint that is not an http(s) URL", () => {
|
||||
expect(() =>
|
||||
adapter.validateConfig?.({ ...baseConfig, endpoint: "not a url" }),
|
||||
).toThrow(/valid http\(s\) URL/);
|
||||
expect(() =>
|
||||
adapter.validateConfig?.({ ...baseConfig, endpoint: "ftp://x" }),
|
||||
).toThrow(/valid http\(s\) URL/);
|
||||
});
|
||||
|
||||
it("treats 2xx and 400 as success and gives distinct errors for 401 and 404", async () => {
|
||||
fetchMock.mockResolvedValueOnce(respond(204));
|
||||
await expect(
|
||||
adapter.testConnection?.({ ...baseConfig, endpoint }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(respond(400));
|
||||
await expect(
|
||||
adapter.testConnection?.({ ...baseConfig, endpoint }),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
fetchMock.mockResolvedValueOnce(respond(401));
|
||||
await expect(
|
||||
adapter.testConnection?.({ ...baseConfig, endpoint, apiKey: "bad" }),
|
||||
).rejects.toThrow(/rejected the credentials \(status 401\)/);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(respond(404));
|
||||
await expect(
|
||||
adapter.testConnection?.({ ...baseConfig, endpoint }),
|
||||
).rejects.toThrow(/not found at this URL \(status 404\)/);
|
||||
});
|
||||
|
||||
it("sends the auth header and the tenant header on the test request", async () => {
|
||||
fetchMock.mockResolvedValueOnce(respond(204));
|
||||
await adapter.testConnection?.({
|
||||
...baseConfig,
|
||||
endpoint,
|
||||
apiKey: "tok",
|
||||
extraConfig: { tenantId: "team-a" },
|
||||
});
|
||||
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers).toMatchObject({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Scope-OrgID": "team-a",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("newRelicAdapter", () => {
|
||||
const adapter = telemetryProviderAdapters.new_relic;
|
||||
const config = {
|
||||
...baseConfig,
|
||||
apiKey: "license",
|
||||
extraConfig: { accountId: "123" },
|
||||
};
|
||||
|
||||
it("defaults to the us region and accepts eu", () => {
|
||||
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
|
||||
{
|
||||
type: "new_relic",
|
||||
api: "metrics",
|
||||
license_key: "license",
|
||||
account_id: "123",
|
||||
region: "us",
|
||||
},
|
||||
);
|
||||
expect(
|
||||
adapter.toVectorSink(
|
||||
{ ...config, extraConfig: { accountId: "123", region: "EU" } },
|
||||
"s",
|
||||
"scope",
|
||||
"metrics",
|
||||
).region,
|
||||
).toBe("eu");
|
||||
});
|
||||
|
||||
it("rejects an unknown region", () => {
|
||||
expect(() =>
|
||||
adapter.validateConfig?.({
|
||||
...config,
|
||||
extraConfig: { accountId: "123", region: "jp" },
|
||||
}),
|
||||
).toThrow(/"us" or "eu"/);
|
||||
});
|
||||
|
||||
it("posts a gauge to the regional metric API: 202 passes, 403 is an invalid key", async () => {
|
||||
fetchMock.mockResolvedValueOnce(respond(202));
|
||||
await expect(
|
||||
adapter.testConnection?.({
|
||||
...config,
|
||||
extraConfig: { accountId: "123", region: "eu" },
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(calledUrls()[0]).toBe(
|
||||
"https://metric-api.eu.newrelic.com/metric/v1",
|
||||
);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(respond(403));
|
||||
await expect(adapter.testConnection?.(config)).rejects.toThrow(
|
||||
/rejected the license key/,
|
||||
);
|
||||
expect(calledUrls()[1]).toBe("https://metric-api.newrelic.com/metric/v1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("influxdbAdapter", () => {
|
||||
const adapter = telemetryProviderAdapters.influxdb;
|
||||
const config = {
|
||||
...baseConfig,
|
||||
endpoint: "http://influx.example.com:8086",
|
||||
apiKey: "token",
|
||||
extraConfig: { org: "dokploy", bucket: "metrics" },
|
||||
};
|
||||
|
||||
it("builds an influxdb_metrics v2 sink", () => {
|
||||
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toEqual({
|
||||
type: "influxdb_metrics",
|
||||
inputs: ["scope"],
|
||||
endpoint: "http://influx.example.com:8086",
|
||||
org: "dokploy",
|
||||
bucket: "metrics",
|
||||
token: "token",
|
||||
default_namespace: "dokploy",
|
||||
version: "2",
|
||||
buffer: { type: "disk", max_size: 268_435_488, when_full: "block" },
|
||||
});
|
||||
});
|
||||
|
||||
it("writes a test point: 204 passes, 401 and 404 give distinct errors", async () => {
|
||||
fetchMock.mockResolvedValueOnce(respond(204));
|
||||
await expect(adapter.testConnection?.(config)).resolves.toBeUndefined();
|
||||
expect(calledUrls()[0]).toBe(
|
||||
"http://influx.example.com:8086/api/v2/write?org=dokploy&bucket=metrics&precision=s",
|
||||
);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(respond(401));
|
||||
await expect(adapter.testConnection?.(config)).rejects.toThrow(
|
||||
/rejected the token/,
|
||||
);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(respond(404));
|
||||
await expect(adapter.testConnection?.(config)).rejects.toThrow(
|
||||
/organization or bucket not found/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("datadogAdapter with metrics", () => {
|
||||
const adapter = telemetryProviderAdapters.datadog;
|
||||
const config = {
|
||||
...baseConfig,
|
||||
signals: ["logs", "metrics"] as const,
|
||||
apiKey: "dd",
|
||||
};
|
||||
|
||||
it("ships metrics through datadog_metrics without a transform", () => {
|
||||
expect(
|
||||
adapter.toVectorTransform?.(config as never, "t", "scope", "metrics"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
adapter.toVectorSink(config as never, "s", "scope", "metrics"),
|
||||
).toMatchObject({
|
||||
type: "datadog_metrics",
|
||||
inputs: ["scope"],
|
||||
default_api_key: "dd",
|
||||
site: "datadoghq.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the logs sink and ddtags transform unchanged", () => {
|
||||
expect(
|
||||
adapter.toVectorTransform?.(config as never, "t", "scope", "logs"),
|
||||
).toMatchObject({ type: "remap", inputs: ["scope"] });
|
||||
expect(adapter.toVectorSink(config as never, "s", "t", "logs").type).toBe(
|
||||
"datadog_logs",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("betterStackAdapter with metrics", () => {
|
||||
const adapter = telemetryProviderAdapters.betterstack;
|
||||
const config = {
|
||||
...baseConfig,
|
||||
endpoint: "in.logs.betterstack.com",
|
||||
apiKey: "token",
|
||||
};
|
||||
|
||||
it("posts metrics to /metrics without a transform", () => {
|
||||
expect(
|
||||
adapter.toVectorTransform?.(config, "t", "scope", "metrics"),
|
||||
).toBeNull();
|
||||
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
|
||||
{
|
||||
type: "http",
|
||||
uri: "https://in.logs.betterstack.com/metrics",
|
||||
auth: { strategy: "bearer", token: "token" },
|
||||
},
|
||||
);
|
||||
expect(adapter.toVectorSink(config, "s", "scope", "logs").uri).toBe(
|
||||
"https://in.logs.betterstack.com/",
|
||||
);
|
||||
});
|
||||
|
||||
it("tests each selected signal: two requests for logs and metrics, one for logs only", async () => {
|
||||
fetchMock.mockResolvedValue(respond(202));
|
||||
await adapter.testConnection?.({ ...config, signals: ["logs", "metrics"] });
|
||||
expect(calledUrls()).toEqual([
|
||||
"https://in.logs.betterstack.com/",
|
||||
"https://in.logs.betterstack.com/metrics",
|
||||
]);
|
||||
|
||||
fetchMock.mockClear();
|
||||
await adapter.testConnection?.({ ...config, signals: ["logs"] });
|
||||
expect(calledUrls()).toEqual(["https://in.logs.betterstack.com/"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("awsCloudwatchAdapter with metrics", () => {
|
||||
const adapter = telemetryProviderAdapters.aws_cloudwatch;
|
||||
const config = {
|
||||
...baseConfig,
|
||||
apiKey: "AKIA",
|
||||
apiSecret: "secret",
|
||||
extraConfig: {
|
||||
region: "us-east-1",
|
||||
logGroup: "/dokploy/logs",
|
||||
metricsNamespace: "Dokploy",
|
||||
},
|
||||
};
|
||||
|
||||
it("scopes the log fields to logs and the namespace to metrics", () => {
|
||||
const bySignal = Object.fromEntries(
|
||||
adapter.credentialFields.map((f) => [f.key, f.signal]),
|
||||
);
|
||||
expect(bySignal).toMatchObject({
|
||||
logGroup: "logs",
|
||||
logStream: "logs",
|
||||
metricsNamespace: "metrics",
|
||||
});
|
||||
expect(bySignal.region).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a namespace starting with AWS/ only when metrics are selected", () => {
|
||||
const bad = {
|
||||
...config,
|
||||
extraConfig: { ...config.extraConfig, metricsNamespace: "AWS/Foo" },
|
||||
};
|
||||
expect(() => adapter.validateConfig?.(bad)).toThrow(
|
||||
/cannot start with AWS\//,
|
||||
);
|
||||
expect(() =>
|
||||
adapter.validateConfig?.({ ...bad, signals: ["logs"] }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("filters metrics to the allowlist and sets the namespace in the transform", () => {
|
||||
const transform = adapter.toVectorTransform?.(
|
||||
config,
|
||||
"t",
|
||||
"scope",
|
||||
"metrics",
|
||||
);
|
||||
expect(transform).toMatchObject({
|
||||
type: "remap",
|
||||
inputs: ["scope"],
|
||||
drop_on_abort: true,
|
||||
});
|
||||
expect(transform?.source).toContain(
|
||||
'if !includes(["cpu_seconds_total","memory_used_bytes","memory_total_bytes","filesystem_used_bytes","filesystem_total_bytes","network_receive_bytes_total","network_transmit_bytes_total","load1","load5","load15","container_cpu_usage_seconds_total","container_memory_usage_bytes","container_network_receive_bytes_total","container_network_transmit_bytes_total"], .name) { abort }',
|
||||
);
|
||||
expect(transform?.source).toContain('.namespace = "Dokploy"');
|
||||
expect(adapter.toVectorSink(config, "s", "t", "metrics")).toMatchObject({
|
||||
type: "aws_cloudwatch_metrics",
|
||||
inputs: ["t"],
|
||||
default_namespace: "Dokploy",
|
||||
region: "us-east-1",
|
||||
auth: { access_key_id: "AKIA", secret_access_key: "secret" },
|
||||
});
|
||||
});
|
||||
|
||||
it("tests metrics with PutMetricData only, and translates AccessDenied", async () => {
|
||||
aws.putMetricData.mockResolvedValue({});
|
||||
await adapter.testConnection?.({ ...config, signals: ["metrics"] });
|
||||
expect(aws.putMetricData).toHaveBeenCalledTimes(1);
|
||||
expect(aws.describeLogGroups).not.toHaveBeenCalled();
|
||||
expect(aws.putMetricData.mock.calls[0]?.[0]).toMatchObject({
|
||||
input: { Namespace: "Dokploy" },
|
||||
});
|
||||
|
||||
aws.putMetricData.mockRejectedValue(
|
||||
Object.assign(new Error("denied"), { name: "AccessDenied" }),
|
||||
);
|
||||
await expect(
|
||||
adapter.testConnection?.({ ...config, signals: ["metrics"] }),
|
||||
).rejects.toThrow(/needs cloudwatch:PutMetricData/);
|
||||
});
|
||||
|
||||
it("tests logs with DescribeLogGroups only", async () => {
|
||||
aws.describeLogGroups.mockResolvedValue({});
|
||||
await adapter.testConnection?.({ ...config, signals: ["logs"] });
|
||||
expect(aws.describeLogGroups).toHaveBeenCalledTimes(1);
|
||||
expect(aws.putMetricData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("splunkAdapter with metrics", () => {
|
||||
const adapter = telemetryProviderAdapters.splunk_hec;
|
||||
const config = {
|
||||
...baseConfig,
|
||||
endpoint: "https://splunk.example.com:8088",
|
||||
apiKey: "hec",
|
||||
extraConfig: { metricsIndex: "dokploy_metrics" },
|
||||
};
|
||||
|
||||
it("requires the metrics index only for metrics", () => {
|
||||
const field = adapter.credentialFields.find(
|
||||
(f) => f.key === "metricsIndex",
|
||||
);
|
||||
expect(field).toMatchObject({ required: true, signal: "metrics" });
|
||||
expect(
|
||||
adapter.credentialFields.find((f) => f.key === "index")?.signal,
|
||||
).toBe("logs");
|
||||
});
|
||||
|
||||
it("builds a splunk_hec_metrics sink", () => {
|
||||
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
|
||||
{
|
||||
type: "splunk_hec_metrics",
|
||||
endpoint: "https://splunk.example.com:8088",
|
||||
default_token: "hec",
|
||||
index: "dokploy_metrics",
|
||||
source: "dokploy",
|
||||
sourcetype: "dokploy:metrics",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("writes a test metric and translates HEC code 7", async () => {
|
||||
fetchMock.mockResolvedValueOnce(respond(200, { text: "Success", code: 0 }));
|
||||
await expect(adapter.testConnection?.(config)).resolves.toBeUndefined();
|
||||
expect(calledUrls()).toEqual([
|
||||
"https://splunk.example.com:8088/services/collector",
|
||||
]);
|
||||
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
respond(400, { text: "Incorrect index", code: 7 }),
|
||||
);
|
||||
await expect(adapter.testConnection?.(config)).rejects.toThrow(
|
||||
/can't write to the metrics index "dokploy_metrics"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("only checks the health endpoint for logs", async () => {
|
||||
fetchMock.mockResolvedValueOnce(respond(200));
|
||||
await adapter.testConnection?.({ ...config, signals: ["logs"] });
|
||||
expect(calledUrls()).toEqual([
|
||||
"https://splunk.example.com:8088/services/collector/health",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("elasticsearchAdapter with metrics", () => {
|
||||
const adapter = telemetryProviderAdapters.elasticsearch;
|
||||
const config = { ...baseConfig, endpoint: "https://es.example.com:9200" };
|
||||
|
||||
it("uses the daily default metrics index when none is set, and the custom one otherwise", () => {
|
||||
expect(
|
||||
adapter.toVectorTransform?.(config, "t", "scope", "metrics"),
|
||||
).toBeNull();
|
||||
expect(adapter.toVectorSink(config, "s", "scope", "metrics")).toMatchObject(
|
||||
{
|
||||
type: "elasticsearch",
|
||||
api_version: "v8",
|
||||
bulk: { index: "dokploy-metrics-%Y.%m.%d" },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
adapter.toVectorSink(
|
||||
{ ...config, extraConfig: { metricsIndex: "metrics-custom" } },
|
||||
"s",
|
||||
"scope",
|
||||
"metrics",
|
||||
).bulk,
|
||||
).toEqual({ index: "metrics-custom" });
|
||||
});
|
||||
|
||||
it("keeps the logs sink without a bulk index unless one is set", () => {
|
||||
expect(adapter.toVectorSink(config, "s", "t", "logs").bulk).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findServersByOrganizationForVectorAgent: vi.fn(),
|
||||
getWebServerSettings: vi.fn(),
|
||||
releaseWebServerAgent: vi.fn(),
|
||||
removeVectorAgent: vi.fn(),
|
||||
deleteWhere: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/constants", () => ({ IS_CLOUD: false }));
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: { delete: () => ({ where: mocks.deleteWhere }) },
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServersByOrganizationForVectorAgent:
|
||||
mocks.findServersByOrganizationForVectorAgent,
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/web-server-settings", () => ({
|
||||
getWebServerSettings: mocks.getWebServerSettings,
|
||||
releaseWebServerAgent: mocks.releaseWebServerAgent,
|
||||
}));
|
||||
vi.mock("@dokploy/server/setup/vector-setup", () => ({
|
||||
removeVectorAgent: mocks.removeVectorAgent,
|
||||
withVectorTargetLock: (_: string | undefined, fn: () => Promise<unknown>) =>
|
||||
fn(),
|
||||
}));
|
||||
|
||||
const { deleteOrganization } = await import(
|
||||
"@dokploy/server/services/organization"
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([]);
|
||||
mocks.deleteWhere.mockResolvedValue({ count: 1 });
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("deleteOrganization", () => {
|
||||
it("attempts cleanup on every server, even with an empty selection, and swallows errors", async () => {
|
||||
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([
|
||||
{ serverId: "server-1", telemetryProviderIds: ["lp-1"] },
|
||||
{ serverId: "server-2", telemetryProviderIds: ["lp-1"] },
|
||||
{ serverId: "server-3", telemetryProviderIds: [] },
|
||||
]);
|
||||
mocks.removeVectorAgent.mockImplementation(async (serverId?: string) => {
|
||||
if (serverId === "server-1") throw new Error("ssh down");
|
||||
});
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: null,
|
||||
});
|
||||
|
||||
await deleteOrganization("org-a");
|
||||
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-2");
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-3");
|
||||
expect(mocks.deleteWhere).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the local agent alone when another organization owns it", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-b",
|
||||
});
|
||||
|
||||
await deleteOrganization("org-a");
|
||||
|
||||
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
|
||||
expect(mocks.releaseWebServerAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases the local agent even when removing it fails", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
});
|
||||
mocks.removeVectorAgent.mockRejectedValue(new Error("docker down"));
|
||||
|
||||
await deleteOrganization("org-a");
|
||||
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith();
|
||||
expect(mocks.releaseWebServerAgent).toHaveBeenCalledWith("org-a");
|
||||
expect(mocks.deleteWhere).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,220 +0,0 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
lookup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:dns", () => ({
|
||||
lookup: mocks.lookup,
|
||||
}));
|
||||
|
||||
const loadModule = async (isCloud: boolean) => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud }));
|
||||
return await import(
|
||||
"@dokploy/server/services/logs-and-metrics/provider-fetch"
|
||||
);
|
||||
};
|
||||
|
||||
const loadFetch = async (isCloud: boolean) =>
|
||||
(await loadModule(isCloud)).providerFetch;
|
||||
|
||||
const resolvesTo = (...addresses: string[]) =>
|
||||
mocks.lookup.mockImplementation(
|
||||
(_host: string, _options: unknown, callback: Function) =>
|
||||
callback(
|
||||
null,
|
||||
addresses.map((address) => ({
|
||||
address,
|
||||
family: address.includes(":") ? 6 : 4,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
describe("providerFetch — blocked endpoint guard", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.lookup.mockReset();
|
||||
global.fetch = vi.fn().mockResolvedValue(new Response("ok"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("rejects a literal AWS/GCP/Azure metadata IP without ever calling fetch", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
await expect(
|
||||
providerFetch("http://169.254.169.254/latest/meta-data/"),
|
||||
).rejects.toThrow(/metadata/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(mocks.lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects the metadata IP written as an IPv4-mapped IPv6 address", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
for (const address of ["[::ffff:169.254.169.254]", "[::ffff:a9fe:a9fe]"]) {
|
||||
await expect(
|
||||
providerFetch(`http://${address}/latest/meta-data/`),
|
||||
`expected ${address} to be rejected`,
|
||||
).rejects.toThrow(/metadata/i);
|
||||
}
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects the AWS ECS metadata IPv6 address", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
await expect(
|
||||
providerFetch("http://[fd00:ec2::254]/v2/credentials"),
|
||||
).rejects.toThrow(/metadata/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects every fe80::/10 link-local address, not just literal fe80: ones", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
for (const address of [
|
||||
"[fe80::1]",
|
||||
"[fe90::1]",
|
||||
"[fea0::1]",
|
||||
"[febf::1]",
|
||||
]) {
|
||||
await expect(
|
||||
providerFetch(`http://${address}/`),
|
||||
`expected ${address} to be rejected`,
|
||||
).rejects.toThrow(/metadata/i);
|
||||
}
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not block a hostname just because it starts like a link-local prefix", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
|
||||
await providerFetch("https://feature-logs.example.com/ready");
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects the GCP metadata hostname without needing DNS resolution", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
await expect(
|
||||
providerFetch("http://metadata.google.internal/computeMetadata/v1/"),
|
||||
).rejects.toThrow(/metadata/i);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(mocks.lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects loopback and private-network endpoints in cloud", async () => {
|
||||
const providerFetch = await loadFetch(true);
|
||||
|
||||
for (const url of [
|
||||
"http://10.0.0.5:3100/ready",
|
||||
"http://127.0.0.1:3100/ready",
|
||||
"http://localhost:3100/ready",
|
||||
"http://[::1]:3100/ready",
|
||||
"http://0.0.0.0:3100/ready",
|
||||
"http://100.100.100.200/latest/meta-data/",
|
||||
"http://[::]:3100/ready",
|
||||
]) {
|
||||
await expect(
|
||||
providerFetch(url),
|
||||
`expected ${url} to be rejected`,
|
||||
).rejects.toThrow(/blocked/i);
|
||||
}
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not follow redirects", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
|
||||
await providerFetch("https://logs.example.com/ready");
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://logs.example.com/ready",
|
||||
expect.objectContaining({ redirect: "error" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("providerFetch — address checked at connect time", () => {
|
||||
let server: Server;
|
||||
let port: number;
|
||||
let hostHeader: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
mocks.lookup.mockReset();
|
||||
hostHeader = undefined;
|
||||
server = createServer((req, res) => {
|
||||
hostHeader = req.headers.host;
|
||||
res.end("ok");
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, "127.0.0.1", resolve),
|
||||
);
|
||||
port = (server.address() as AddressInfo).port;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
it("rejects a hostname whose lookup returns a metadata address", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
resolvesTo("203.0.113.10", "169.254.169.254");
|
||||
|
||||
await expect(
|
||||
providerFetch("http://attacker-controlled.example.com/"),
|
||||
).rejects.toThrow(/metadata/i);
|
||||
expect(mocks.lookup).toHaveBeenCalledWith(
|
||||
"attacker-controlled.example.com",
|
||||
expect.anything(),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a hostname resolving to a private address in cloud", async () => {
|
||||
const providerFetch = await loadFetch(true);
|
||||
resolvesTo("127.0.0.1");
|
||||
|
||||
await expect(
|
||||
providerFetch(`http://loki.internal.example.com:${port}/ready`),
|
||||
).rejects.toThrow(/blocked/i);
|
||||
expect(hostHeader).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects when DNS resolution fails", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
mocks.lookup.mockImplementation(
|
||||
(_host: string, _options: unknown, callback: Function) =>
|
||||
callback(Object.assign(new Error("ENOTFOUND"), { code: "ENOTFOUND" })),
|
||||
);
|
||||
|
||||
await expect(
|
||||
providerFetch("http://does-not-exist.invalid/"),
|
||||
).rejects.toThrow();
|
||||
expect(hostHeader).toBeUndefined();
|
||||
});
|
||||
|
||||
it("connects to the address it validated, keeping the original Host header (self-hosted private endpoint)", async () => {
|
||||
const providerFetch = await loadFetch(false);
|
||||
resolvesTo("127.0.0.1");
|
||||
|
||||
const response = await providerFetch(
|
||||
`http://loki.internal.example.com:${port}/ready`,
|
||||
);
|
||||
expect(await response.text()).toBe("ok");
|
||||
expect(hostHeader).toBe(`loki.internal.example.com:${port}`);
|
||||
});
|
||||
|
||||
it("passes a public address through in cloud", async () => {
|
||||
const { guardedLookup } = await loadModule(true);
|
||||
resolvesTo("203.0.113.10");
|
||||
|
||||
const result = await new Promise((resolve, reject) =>
|
||||
guardedLookup("api.datadoghq.com", { all: true }, (error, addresses) =>
|
||||
error ? reject(error) : resolve(addresses),
|
||||
),
|
||||
);
|
||||
expect(result).toEqual([{ address: "203.0.113.10", family: 4 }]);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { providerLabel } from "@/components/dashboard/settings/logs-and-metrics/show-servers";
|
||||
|
||||
describe("providerLabel", () => {
|
||||
it("names what the provider sends next to its name", () => {
|
||||
expect(providerLabel({ name: "Loki prod", signals: ["logs"] })).toBe(
|
||||
"Loki prod (logs)",
|
||||
);
|
||||
expect(providerLabel({ name: "Splunk", signals: ["metrics"] })).toBe(
|
||||
"Splunk (metrics)",
|
||||
);
|
||||
expect(
|
||||
providerLabel({ name: "Datadog", signals: ["logs", "metrics"] }),
|
||||
).toBe("Datadog (logs and metrics)");
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
deleted: [] as unknown[],
|
||||
updates: [] as Array<{ table: unknown; values: Record<string, unknown> }>,
|
||||
updatedServers: [] as Array<{ serverId: string }>,
|
||||
updatedLocal: [] as Array<{ id: string }>,
|
||||
queried: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => {
|
||||
const tx = {
|
||||
delete: () => ({
|
||||
where: () => ({
|
||||
returning: () => Promise.resolve(mocks.deleted),
|
||||
}),
|
||||
}),
|
||||
update: (table: unknown) => ({
|
||||
set: (values: Record<string, unknown>) => ({
|
||||
where: () => ({
|
||||
returning: () => {
|
||||
mocks.updates.push({ table, values });
|
||||
return Promise.resolve(
|
||||
mocks.updates.length === 1
|
||||
? mocks.updatedServers
|
||||
: mocks.updatedLocal,
|
||||
);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
return {
|
||||
db: {
|
||||
transaction: (fn: (tx: unknown) => unknown) => fn(tx),
|
||||
query: {
|
||||
server: { findMany: mocks.queried },
|
||||
webServerSettings: { findFirst: mocks.queried },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { server, webServerSettings } = await import("@dokploy/server/db/schema");
|
||||
const { removeTelemetryProvider } = await import(
|
||||
"@dokploy/server/services/logs-and-metrics/service"
|
||||
);
|
||||
|
||||
const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql;
|
||||
|
||||
describe("removeTelemetryProvider", () => {
|
||||
beforeEach(() => {
|
||||
mocks.updates = [];
|
||||
mocks.updatedServers = [{ serverId: "server-1" }];
|
||||
mocks.updatedLocal = [{ id: "ws-1" }];
|
||||
mocks.queried.mockReset();
|
||||
});
|
||||
|
||||
it("drops the provider id from the selection of every server and of the local host", async () => {
|
||||
mocks.deleted = [{ telemetryProviderId: "lp-1" }];
|
||||
|
||||
const { provider, targets } = await removeTelemetryProvider("lp-1");
|
||||
expect(provider).toEqual({ telemetryProviderId: "lp-1" });
|
||||
expect(targets).toEqual(["server-1", null]);
|
||||
|
||||
expect(mocks.updates.map((u) => u.table)).toEqual([
|
||||
server,
|
||||
webServerSettings,
|
||||
]);
|
||||
expect(render(mocks.updates[0]?.values.telemetryProviderIds)).toBe(
|
||||
'array_remove("server"."telemetryProviderIds", $1)',
|
||||
);
|
||||
expect(render(mocks.updates[1]?.values.telemetryProviderIds)).toBe(
|
||||
'array_remove("webServerSettings"."telemetryProviderIds", $1)',
|
||||
);
|
||||
expect(render(mocks.updates[1]?.values.vectorAgentOrganizationId)).toBe(
|
||||
'CASE WHEN cardinality(array_remove("webServerSettings"."telemetryProviderIds", $1)) = 0 THEN NULL ELSE "webServerSettings"."vectorAgentOrganizationId" END',
|
||||
);
|
||||
});
|
||||
|
||||
it("reconciles exactly the hosts whose selection the transaction updated", async () => {
|
||||
mocks.deleted = [{ telemetryProviderId: "lp-1" }];
|
||||
mocks.updatedServers = [{ serverId: "server-1" }, { serverId: "server-2" }];
|
||||
mocks.updatedLocal = [];
|
||||
|
||||
const { targets } = await removeTelemetryProvider("lp-1");
|
||||
expect(targets).toEqual(["server-1", "server-2"]);
|
||||
expect(mocks.queried).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not touch assignments when the provider does not exist", async () => {
|
||||
mocks.deleted = [];
|
||||
|
||||
await expect(removeTelemetryProvider("missing")).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
expect(mocks.updates).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,392 +0,0 @@
|
||||
import {
|
||||
getTelemetryProviderAdapter,
|
||||
telemetryProviderAdapters,
|
||||
} from "@dokploy/server/services/logs-and-metrics/providers/registry";
|
||||
import type { TelemetryProviderRuntimeConfig } from "@dokploy/server/services/logs-and-metrics/types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const baseConfig: TelemetryProviderRuntimeConfig = {
|
||||
telemetryProviderId: "provider-1",
|
||||
name: "test",
|
||||
signals: ["logs"],
|
||||
endpoint: null,
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
};
|
||||
|
||||
describe("getTelemetryProviderAdapter", () => {
|
||||
it("returns the registered adapter for each known type", () => {
|
||||
expect(getTelemetryProviderAdapter("loki").type).toBe("loki");
|
||||
expect(getTelemetryProviderAdapter("datadog").type).toBe("datadog");
|
||||
expect(getTelemetryProviderAdapter("betterstack").type).toBe("betterstack");
|
||||
expect(getTelemetryProviderAdapter("elasticsearch").type).toBe(
|
||||
"elasticsearch",
|
||||
);
|
||||
expect(getTelemetryProviderAdapter("splunk_hec").type).toBe("splunk_hec");
|
||||
expect(getTelemetryProviderAdapter("aws_cloudwatch").type).toBe(
|
||||
"aws_cloudwatch",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws an explicit error for an unregistered type", () => {
|
||||
// @ts-expect-error: intentionally an unregistered type
|
||||
expect(() => getTelemetryProviderAdapter("unknown")).toThrow(
|
||||
/No TelemetryProviderAdapter registered/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lokiAdapter.toVectorSink", () => {
|
||||
it("maps endpoint + labels, without tenant_id if not set", () => {
|
||||
const adapter = telemetryProviderAdapters.loki;
|
||||
const sink = adapter.toVectorSink(
|
||||
{ ...baseConfig, endpoint: "https://loki.example.com" },
|
||||
"sink_1",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.type).toBe("loki");
|
||||
expect(sink.inputs).toEqual(["dokploy_scope"]);
|
||||
expect(sink.endpoint).toBe("https://loki.example.com");
|
||||
expect(sink.labels).toMatchObject({
|
||||
dokploy_project: "{{ dokploy_project }}",
|
||||
dokploy_environment: "{{ dokploy_environment }}",
|
||||
dokploy_application: "{{ dokploy_application }}",
|
||||
dokploy_organization: "{{ dokploy_organization }}",
|
||||
dokploy_server: "{{ dokploy_server }}",
|
||||
});
|
||||
expect(sink.tenant_id).toBeUndefined();
|
||||
expect(sink.buffer).toEqual({
|
||||
type: "disk",
|
||||
max_size: 268_435_488,
|
||||
when_full: "block",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes tenant_id when present in extraConfig", () => {
|
||||
const adapter = telemetryProviderAdapters.loki;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://loki.example.com",
|
||||
extraConfig: { tenantId: "tenant-a" },
|
||||
},
|
||||
"sink_1",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.tenant_id).toBe("tenant-a");
|
||||
});
|
||||
|
||||
it("has no toVectorTransform — goes directly scope -> sink", () => {
|
||||
expect(telemetryProviderAdapters.loki.toVectorTransform).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("datadogAdapter.toVectorSink", () => {
|
||||
it("defaults site to datadoghq.com and sets no `tags` field (datadog_logs has none)", () => {
|
||||
const adapter = telemetryProviderAdapters.datadog;
|
||||
const sink = adapter.toVectorSink(
|
||||
{ ...baseConfig, apiKey: "dd-key" },
|
||||
"sink_2",
|
||||
"transform_2",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.type).toBe("datadog_logs");
|
||||
expect(sink.default_api_key).toBe("dd-key");
|
||||
expect(sink.site).toBe("datadoghq.com");
|
||||
expect(sink.tags).toBeUndefined();
|
||||
expect(sink.inputs).toEqual(["transform_2"]);
|
||||
});
|
||||
|
||||
it("uses extraConfig.site when present", () => {
|
||||
const adapter = telemetryProviderAdapters.datadog;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
apiKey: "dd-key",
|
||||
extraConfig: { site: "datadoghq.eu" },
|
||||
},
|
||||
"sink_2",
|
||||
"transform_2",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.site).toBe("datadoghq.eu");
|
||||
});
|
||||
|
||||
it("strips a pasted scheme/trailing slash from extraConfig.site instead of interpolating it as-is", () => {
|
||||
const adapter = telemetryProviderAdapters.datadog;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
apiKey: "dd-key",
|
||||
extraConfig: { site: "https://datadoghq.eu/" },
|
||||
},
|
||||
"sink_2",
|
||||
"transform_2",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.site).toBe("datadoghq.eu");
|
||||
});
|
||||
|
||||
it("builds .ddtags from the scoping fields in toVectorTransform, since datadog_logs has no `tags` option (vector generate-schema)", () => {
|
||||
const adapter = telemetryProviderAdapters.datadog;
|
||||
expect(adapter.toVectorTransform).toBeDefined();
|
||||
const transform = adapter.toVectorTransform?.(
|
||||
baseConfig,
|
||||
"transform_2",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(transform?.type).toBe("remap");
|
||||
expect(transform?.inputs).toEqual(["dokploy_scope"]);
|
||||
expect(transform?.source).toContain(".ddtags");
|
||||
expect(transform?.source).toContain(
|
||||
'"dokploy_server:" + replace(to_string!(.dokploy_server), ",", "_")',
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes commas in scoping field values before building .ddtags, since ddtags is itself comma delimited", () => {
|
||||
const adapter = telemetryProviderAdapters.datadog;
|
||||
const transform = adapter.toVectorTransform?.(
|
||||
baseConfig,
|
||||
"transform_2",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(transform?.source).toContain(
|
||||
'replace(to_string!(.dokploy_project), ",", "_")',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("betterStackAdapter", () => {
|
||||
it("toVectorTransform renames timestamp to dt, inputs point to scope transform", () => {
|
||||
const adapter = telemetryProviderAdapters.betterstack;
|
||||
expect(adapter.toVectorTransform).toBeDefined();
|
||||
const transform = adapter.toVectorTransform?.(
|
||||
baseConfig,
|
||||
"transform_1",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(transform).toEqual({
|
||||
type: "remap",
|
||||
inputs: ["dokploy_scope"],
|
||||
source: ".dt = del(.timestamp)",
|
||||
});
|
||||
});
|
||||
|
||||
it("toVectorSink builds a generic http sink with bearer auth, reading from the transform id", () => {
|
||||
const adapter = telemetryProviderAdapters.betterstack;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://in.logs.betterstack.com",
|
||||
apiKey: "source-token",
|
||||
},
|
||||
"sink_3",
|
||||
"transform_1",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.type).toBe("http");
|
||||
expect(sink.inputs).toEqual(["transform_1"]);
|
||||
expect(sink.uri).toBe("https://in.logs.betterstack.com/");
|
||||
expect(sink.auth).toEqual({ strategy: "bearer", token: "source-token" });
|
||||
expect(sink.compression).toBe("gzip");
|
||||
});
|
||||
});
|
||||
|
||||
describe("elasticsearchAdapter", () => {
|
||||
it("has a toVectorTransform that flattens .label to a JSON string", () => {
|
||||
const adapter = telemetryProviderAdapters.elasticsearch;
|
||||
expect(adapter.toVectorTransform).toBeDefined();
|
||||
const transform = adapter.toVectorTransform?.(
|
||||
baseConfig,
|
||||
"transform_4",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(transform?.type).toBe("remap");
|
||||
expect(transform?.inputs).toEqual(["dokploy_scope"]);
|
||||
expect(transform?.source).toContain("encode_json(.label)");
|
||||
});
|
||||
|
||||
it("uses basic auth when a username is set", () => {
|
||||
const adapter = telemetryProviderAdapters.elasticsearch;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: "secret",
|
||||
extraConfig: { username: "elastic" },
|
||||
},
|
||||
"sink_4",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.type).toBe("elasticsearch");
|
||||
expect(sink.endpoints).toEqual(["https://es.example.com:9200"]);
|
||||
expect(sink.auth).toEqual({
|
||||
strategy: "basic",
|
||||
user: "elastic",
|
||||
password: "secret",
|
||||
});
|
||||
expect(sink.request).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to an ApiKey header when no username is set", () => {
|
||||
const adapter = telemetryProviderAdapters.elasticsearch;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: "key-123",
|
||||
},
|
||||
"sink_4",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.auth).toBeUndefined();
|
||||
expect(sink.request).toEqual({
|
||||
headers: { Authorization: "ApiKey key-123" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a username set without a password instead of silently shipping unauthenticated", () => {
|
||||
const adapter = telemetryProviderAdapters.elasticsearch;
|
||||
expect(() =>
|
||||
adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://es.example.com:9200",
|
||||
extraConfig: { username: "elastic" },
|
||||
},
|
||||
"sink_4",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
),
|
||||
).toThrow(/Password.*required/i);
|
||||
});
|
||||
|
||||
it("includes bulk.index only when extraConfig.index is set", () => {
|
||||
const adapter = telemetryProviderAdapters.elasticsearch;
|
||||
const withoutIndex = adapter.toVectorSink(
|
||||
{ ...baseConfig, endpoint: "https://es.example.com:9200" },
|
||||
"sink_4",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(withoutIndex.bulk).toBeUndefined();
|
||||
const withIndex = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://es.example.com:9200",
|
||||
extraConfig: { index: "dokploy-%Y.%m.%d" },
|
||||
},
|
||||
"sink_4",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(withIndex.bulk).toEqual({ index: "dokploy-%Y.%m.%d" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("splunkAdapter", () => {
|
||||
it("has no toVectorTransform — scoping fields ride along as plain JSON fields", () => {
|
||||
expect(
|
||||
telemetryProviderAdapters.splunk_hec.toVectorTransform,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("toVectorSink maps endpoint/token, only sets index/sourcetype when present", () => {
|
||||
const adapter = telemetryProviderAdapters.splunk_hec;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://splunk.example.com:8088",
|
||||
apiKey: "hec-token",
|
||||
},
|
||||
"sink_5",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.type).toBe("splunk_hec_logs");
|
||||
expect(sink.endpoint).toBe("https://splunk.example.com:8088");
|
||||
expect(sink.default_token).toBe("hec-token");
|
||||
expect(sink.encoding).toEqual({ codec: "json" });
|
||||
expect(sink.index).toBeUndefined();
|
||||
expect(sink.sourcetype).toBeUndefined();
|
||||
|
||||
const withExtras = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
endpoint: "https://splunk.example.com:8088",
|
||||
apiKey: "hec-token",
|
||||
extraConfig: { index: "dokploy", sourcetype: "docker" },
|
||||
},
|
||||
"sink_5",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(withExtras.index).toBe("dokploy");
|
||||
expect(withExtras.sourcetype).toBe("docker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("awsCloudwatchAdapter", () => {
|
||||
it("has no logs transform — scoping fields ride along as plain JSON fields", () => {
|
||||
expect(
|
||||
telemetryProviderAdapters.aws_cloudwatch.toVectorTransform?.(
|
||||
baseConfig,
|
||||
"transform_1",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("toVectorSink maps access key/secret, region and log group/stream", () => {
|
||||
const adapter = telemetryProviderAdapters.aws_cloudwatch;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
apiKey: "AKIA...",
|
||||
apiSecret: "shh",
|
||||
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
|
||||
},
|
||||
"sink_6",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.type).toBe("aws_cloudwatch_logs");
|
||||
expect(sink.group_name).toBe("/dokploy/logs");
|
||||
expect(sink.stream_name).toBe("{{ container_name }}");
|
||||
expect(sink.region).toBe("us-east-1");
|
||||
expect(sink.auth).toEqual({
|
||||
access_key_id: "AKIA...",
|
||||
secret_access_key: "shh",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses extraConfig.logStream as the stream template when set", () => {
|
||||
const adapter = telemetryProviderAdapters.aws_cloudwatch;
|
||||
const sink = adapter.toVectorSink(
|
||||
{
|
||||
...baseConfig,
|
||||
apiKey: "AKIA...",
|
||||
apiSecret: "shh",
|
||||
extraConfig: {
|
||||
region: "us-east-1",
|
||||
logGroup: "/dokploy/logs",
|
||||
logStream: "{{ dokploy_application }}",
|
||||
},
|
||||
},
|
||||
"sink_6",
|
||||
"dokploy_scope",
|
||||
"logs",
|
||||
);
|
||||
expect(sink.stream_name).toBe("{{ dokploy_application }}");
|
||||
});
|
||||
});
|
||||
@@ -1,661 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
insertValues: vi.fn(),
|
||||
updateSet: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
serverFindFirst: vi.fn(),
|
||||
serverFindMany: vi.fn(),
|
||||
webServerFindFirst: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
insert: () => ({
|
||||
values: (v: any) => ({
|
||||
returning: () => Promise.resolve(mocks.insertValues(v)),
|
||||
}),
|
||||
}),
|
||||
update: () => ({
|
||||
set: (v: any) => ({
|
||||
where: () => ({
|
||||
returning: () => Promise.resolve(mocks.updateSet(v)),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
query: {
|
||||
telemetryProvider: {
|
||||
findFirst: mocks.findFirst,
|
||||
findMany: mocks.findMany,
|
||||
},
|
||||
server: {
|
||||
findFirst: mocks.serverFindFirst,
|
||||
findMany: mocks.serverFindMany,
|
||||
},
|
||||
webServerSettings: { findFirst: mocks.webServerFindFirst },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const {
|
||||
assertProvidersBelongToOrg,
|
||||
assertSignalsSupported,
|
||||
createTelemetryProvider,
|
||||
testTelemetryProviderConnection,
|
||||
updateTelemetryProvider,
|
||||
} = await import("@dokploy/server/services/logs-and-metrics/service");
|
||||
const { telemetryProviderAdapters } = await import(
|
||||
"@dokploy/server/services/logs-and-metrics/providers/registry"
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.serverFindMany.mockResolvedValue([]);
|
||||
mocks.webServerFindFirst.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("updateTelemetryProvider — targets to re-apply", () => {
|
||||
const existing = {
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: "old",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.findFirst.mockResolvedValue(existing);
|
||||
mocks.updateSet.mockReturnValue([{ ...existing, apiKey: "new" }]);
|
||||
mocks.serverFindMany.mockResolvedValue([{ serverId: "server-1" }]);
|
||||
mocks.webServerFindFirst.mockResolvedValue({ id: "ws-1" });
|
||||
});
|
||||
|
||||
it("returns the servers and the local host that ship with it when a credential changes", async () => {
|
||||
const { targets } = await updateTelemetryProvider("lp-1", {
|
||||
apiKey: "new",
|
||||
});
|
||||
expect(targets).toEqual(["server-1", null]);
|
||||
});
|
||||
|
||||
it("returns the same targets when the provider is disabled", async () => {
|
||||
const { targets } = await updateTelemetryProvider("lp-1", {
|
||||
enabled: false,
|
||||
});
|
||||
expect(targets).toEqual(["server-1", null]);
|
||||
});
|
||||
|
||||
it("returns no targets when only the name changes or a value is re-sent unchanged", async () => {
|
||||
expect(
|
||||
(await updateTelemetryProvider("lp-1", { name: "renamed" })).targets,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
(
|
||||
await updateTelemetryProvider("lp-1", {
|
||||
endpoint: "https://loki.example.com",
|
||||
})
|
||||
).targets,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTelemetryProvider — required credential fields per adapter", () => {
|
||||
it("rejects a loki provider without endpoint", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{ name: "loki-prod", providerType: "loki", signals: ["logs"] } as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/endpoint/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a datadog provider without apiKey", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{ name: "dd", providerType: "datadog", signals: ["logs"] } as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a betterstack provider missing either field", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "bs",
|
||||
providerType: "betterstack",
|
||||
signals: ["logs"],
|
||||
apiKey: "token-only",
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a splunk_hec provider without a HEC token", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "splunk",
|
||||
providerType: "splunk_hec",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://splunk.example.com:8088",
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/token/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an aws_cloudwatch provider missing required extraConfig fields (region/log group)", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["logs"],
|
||||
apiKey: "AKIA...",
|
||||
apiSecret: "shh",
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/region|log group/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a datadog provider whose apiKey only exists inside extraConfig, not the real column", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "dd",
|
||||
providerType: "datadog",
|
||||
signals: ["logs"],
|
||||
extraConfig: { apiKey: "sneaky-value" },
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/api key/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an elasticsearch provider with a username but no password (adapter.validateConfig)", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "es",
|
||||
providerType: "elasticsearch",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://es.example.com:9200",
|
||||
extraConfig: { username: "elastic" },
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/password.*required/i);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts an aws_cloudwatch provider with region and log group set via extraConfig", async () => {
|
||||
mocks.insertValues.mockReturnValue([
|
||||
{
|
||||
telemetryProviderId: "lp-2",
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["logs"],
|
||||
},
|
||||
]);
|
||||
const created = await createTelemetryProvider(
|
||||
{
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["logs"],
|
||||
apiKey: "AKIA...",
|
||||
apiSecret: "shh",
|
||||
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
|
||||
} as any,
|
||||
"org-1",
|
||||
);
|
||||
expect(created.telemetryProviderId).toBe("lp-2");
|
||||
});
|
||||
|
||||
it("accepts a loki provider with endpoint", async () => {
|
||||
mocks.insertValues.mockReturnValue([
|
||||
{ telemetryProviderId: "lp-1", name: "loki-prod", providerType: "loki" },
|
||||
]);
|
||||
const created = await createTelemetryProvider(
|
||||
{
|
||||
name: "loki-prod",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
} as any,
|
||||
"org-1",
|
||||
);
|
||||
expect(created.telemetryProviderId).toBe("lp-1");
|
||||
expect(mocks.insertValues).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTelemetryProvider — merges partial update with existing row before validating", () => {
|
||||
it("allows updating just the name without re-sending existing credentials", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "old-name",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([
|
||||
{ telemetryProviderId: "lp-1", name: "new-name" },
|
||||
]);
|
||||
|
||||
const { provider: updated, targets } = await updateTelemetryProvider(
|
||||
"lp-1",
|
||||
{ name: "new-name" },
|
||||
);
|
||||
expect(updated.name).toBe("new-name");
|
||||
expect(targets).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects clearing the only required field via update", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "loki-prod",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
updateTelemetryProvider("lp-1", { endpoint: null }),
|
||||
).rejects.toThrow(/endpoint/i);
|
||||
});
|
||||
|
||||
it("clears the old provider's credentials when providerType changes, unless new values are given", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "dd-prod",
|
||||
providerType: "datadog",
|
||||
signals: ["logs"],
|
||||
endpoint: null,
|
||||
apiKey: "old-datadog-key",
|
||||
apiSecret: null,
|
||||
extraConfig: { site: "datadoghq.com" },
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
|
||||
|
||||
await updateTelemetryProvider("lp-1", {
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
});
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("merges a partial extraConfig update instead of replacing it wholesale", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "es-prod",
|
||||
providerType: "elasticsearch",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: "secret",
|
||||
apiSecret: null,
|
||||
extraConfig: { username: "elastic", index: "custom-index" },
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
|
||||
|
||||
await updateTelemetryProvider("lp-1", {
|
||||
extraConfig: { username: "new-user" },
|
||||
});
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extraConfig: { username: "new-user", index: "custom-index" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears extraConfig entirely when explicitly set to null, instead of merging", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "es-prod",
|
||||
providerType: "elasticsearch",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: { username: "elastic" },
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
|
||||
|
||||
await updateTelemetryProvider("lp-1", { extraConfig: null });
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ extraConfig: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears an optional credential when it is explicitly set to null", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "loki-prod",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: "old-token",
|
||||
apiSecret: null,
|
||||
extraConfig: { username: "user" },
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "lp-1" }]);
|
||||
|
||||
await updateTelemetryProvider("lp-1", { apiKey: null });
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ apiKey: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects clearing the elasticsearch password while a username stays saved", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "es-prod",
|
||||
providerType: "elasticsearch",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://es.example.com:9200",
|
||||
apiKey: "old-password",
|
||||
apiSecret: null,
|
||||
extraConfig: { username: "elastic" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
updateTelemetryProvider("lp-1", { apiKey: null }),
|
||||
).rejects.toThrow(/Password/);
|
||||
});
|
||||
|
||||
it("rejects a providerType change that doesn't satisfy the new type's required fields", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "dd-prod",
|
||||
providerType: "datadog",
|
||||
signals: ["logs"],
|
||||
endpoint: null,
|
||||
apiKey: "old-datadog-key",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
updateTelemetryProvider("lp-1", { providerType: "loki" }),
|
||||
).rejects.toThrow(/endpoint/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertSignalsSupported", () => {
|
||||
it("rejects a repeated signal", () => {
|
||||
expect(() => assertSignalsSupported("loki", ["logs", "logs"])).toThrow(
|
||||
/only be selected once/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a signal the type cannot send", () => {
|
||||
expect(() => assertSignalsSupported("loki", ["metrics"])).toThrow(
|
||||
/Grafana Loki cannot send metrics/,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a supported subset", () => {
|
||||
expect(() => assertSignalsSupported("loki", ["logs"])).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTelemetryProvider — signals", () => {
|
||||
it("rejects a provider whose type cannot send the selected signal", async () => {
|
||||
mocks.insertValues.mockClear();
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
signals: ["metrics"],
|
||||
endpoint: "https://loki.example.com",
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/cannot send metrics/);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertProvidersBelongToOrg", () => {
|
||||
beforeEach(() => {
|
||||
mocks.findMany.mockResolvedValue([
|
||||
{
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "loki",
|
||||
enabled: true,
|
||||
signals: ["logs"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts a provider of the organization regardless of what it sends", async () => {
|
||||
await expect(
|
||||
assertProvidersBelongToOrg(["lp-1"], "org-1"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects an id that is not in the organization", async () => {
|
||||
await expect(
|
||||
assertProvidersBelongToOrg(["lp-9"], "org-1"),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
});
|
||||
|
||||
it("rejects a selection where every provider is disabled", async () => {
|
||||
mocks.findMany.mockResolvedValue([
|
||||
{
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "loki",
|
||||
enabled: false,
|
||||
signals: ["logs"],
|
||||
},
|
||||
]);
|
||||
await expect(assertProvidersBelongToOrg(["lp-1"], "org-1")).rejects.toThrow(
|
||||
/at least one enabled provider/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("required fields scoped to a signal", () => {
|
||||
beforeEach(() => {
|
||||
mocks.insertValues.mockReset();
|
||||
mocks.insertValues.mockReturnValue([{ telemetryProviderId: "cw-1" }]);
|
||||
});
|
||||
|
||||
it("does not require the log group when only metrics are selected", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["metrics"],
|
||||
apiKey: "AKIA",
|
||||
apiSecret: "shh",
|
||||
extraConfig: { region: "us-east-1", metricsNamespace: "Dokploy" },
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).resolves.toMatchObject({ telemetryProviderId: "cw-1" });
|
||||
});
|
||||
|
||||
it("does not require the metrics namespace when only logs are selected", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["logs"],
|
||||
apiKey: "AKIA",
|
||||
apiSecret: "shh",
|
||||
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).resolves.toMatchObject({ telemetryProviderId: "cw-1" });
|
||||
});
|
||||
|
||||
it("requires the metrics namespace when metrics are selected", async () => {
|
||||
await expect(
|
||||
createTelemetryProvider(
|
||||
{
|
||||
name: "cw",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["logs", "metrics"],
|
||||
apiKey: "AKIA",
|
||||
apiSecret: "shh",
|
||||
extraConfig: { region: "us-east-1", logGroup: "/dokploy/logs" },
|
||||
} as any,
|
||||
"org-1",
|
||||
),
|
||||
).rejects.toThrow(/Metrics namespace/);
|
||||
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("signals are stored in a fixed order", () => {
|
||||
it("normalizes the order on create", async () => {
|
||||
mocks.insertValues.mockReset();
|
||||
mocks.insertValues.mockReturnValue([{ telemetryProviderId: "dd-1" }]);
|
||||
|
||||
await createTelemetryProvider(
|
||||
{
|
||||
name: "dd",
|
||||
providerType: "datadog",
|
||||
signals: ["metrics", "logs"],
|
||||
apiKey: "dd-key",
|
||||
} as any,
|
||||
"org-1",
|
||||
);
|
||||
|
||||
expect(mocks.insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signals: ["logs", "metrics"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes the order on update", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "dd-1",
|
||||
name: "dd",
|
||||
providerType: "datadog",
|
||||
signals: ["logs"],
|
||||
endpoint: null,
|
||||
apiKey: "dd-key",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
mocks.updateSet.mockReturnValue([{ telemetryProviderId: "dd-1" }]);
|
||||
|
||||
await updateTelemetryProvider("dd-1", { signals: ["metrics", "logs"] });
|
||||
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signals: ["logs", "metrics"] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("testTelemetryProviderConnection by id", () => {
|
||||
it("tests the saved provider with the signals override from the form", async () => {
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
telemetryProviderId: "bs-1",
|
||||
name: "bs",
|
||||
providerType: "betterstack",
|
||||
signals: ["logs"],
|
||||
endpoint: "in.logs.betterstack.com",
|
||||
apiKey: "token",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
});
|
||||
const spy = vi
|
||||
.spyOn(telemetryProviderAdapters.betterstack, "testConnection")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await testTelemetryProviderConnection({
|
||||
telemetryProviderId: "bs-1",
|
||||
signals: ["logs", "metrics"],
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: "token",
|
||||
signals: ["logs", "metrics"],
|
||||
}),
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTelemetryProvider — signals", () => {
|
||||
const existing = {
|
||||
telemetryProviderId: "dd-1",
|
||||
name: "datadog",
|
||||
providerType: "datadog",
|
||||
signals: ["logs", "metrics"],
|
||||
endpoint: null,
|
||||
apiKey: "dd-key",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.updateSet.mockReset();
|
||||
mocks.serverFindFirst.mockReset();
|
||||
mocks.webServerFindFirst.mockReset();
|
||||
mocks.findFirst.mockResolvedValue(existing);
|
||||
mocks.updateSet.mockReturnValue([{ ...existing, signals: ["metrics"] }]);
|
||||
});
|
||||
|
||||
it("allows removing a signal while the provider is assigned and returns its targets to re-apply", async () => {
|
||||
mocks.serverFindMany.mockResolvedValue([{ serverId: "server-1" }]);
|
||||
mocks.webServerFindFirst.mockResolvedValue({ id: "ws-1" });
|
||||
|
||||
await expect(
|
||||
updateTelemetryProvider("dd-1", { signals: ["metrics"] }),
|
||||
).resolves.toMatchObject({
|
||||
provider: { signals: ["metrics"] },
|
||||
targets: ["server-1", null],
|
||||
});
|
||||
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signals: ["metrics"] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a type change that cannot send the saved signals", async () => {
|
||||
await expect(
|
||||
updateTelemetryProvider("dd-1", {
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
}),
|
||||
).rejects.toThrow(/Grafana Loki cannot send metrics/);
|
||||
});
|
||||
});
|
||||
@@ -1,305 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
setWebServerProviderIds: vi.fn(),
|
||||
setServerProviderIds: vi.fn(),
|
||||
getAccessibleServerIds: vi.fn(),
|
||||
reconcileVectorAgent: vi.fn(),
|
||||
removeVectorAgent: vi.fn(),
|
||||
findServerById: vi.fn(),
|
||||
assertProvidersBelongToOrg: vi.fn(),
|
||||
}));
|
||||
|
||||
const loadAgent = async (isCloud: boolean) => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@dokploy/server/constants", () => ({ IS_CLOUD: isCloud }));
|
||||
vi.doMock("@dokploy/server/services/server", () => ({
|
||||
findServerById: mocks.findServerById,
|
||||
setServerProviderIds: mocks.setServerProviderIds,
|
||||
getAccessibleServerIds: mocks.getAccessibleServerIds,
|
||||
}));
|
||||
vi.doMock("@dokploy/server/services/web-server-settings", () => ({
|
||||
setWebServerProviderIds: mocks.setWebServerProviderIds,
|
||||
}));
|
||||
const locks = new Map<string, Promise<unknown>>();
|
||||
vi.doMock("@dokploy/server/setup/vector-setup", () => ({
|
||||
LOCAL_SERVER_NAME: "Dokploy Server (local)",
|
||||
reconcileVectorAgent: mocks.reconcileVectorAgent,
|
||||
removeVectorAgent: mocks.removeVectorAgent,
|
||||
withVectorTargetLock: (
|
||||
serverId: string | undefined,
|
||||
fn: () => Promise<unknown>,
|
||||
) => {
|
||||
const key = serverId ?? "local";
|
||||
const current = (locks.get(key) ?? Promise.resolve())
|
||||
.catch(() => {})
|
||||
.then(fn);
|
||||
locks.set(key, current);
|
||||
return current;
|
||||
},
|
||||
}));
|
||||
vi.doMock("@dokploy/server/services/logs-and-metrics/service", () => ({
|
||||
assertProvidersBelongToOrg: mocks.assertProvidersBelongToOrg,
|
||||
}));
|
||||
return await import("@dokploy/server/services/logs-and-metrics/vector-agent");
|
||||
};
|
||||
|
||||
const sessionA = { userId: "user-a", activeOrganizationId: "org-a" };
|
||||
const sessionB = { userId: "user-b", activeOrganizationId: "org-b" };
|
||||
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-1"]));
|
||||
mocks.findServerById.mockImplementation(async (serverId: string) => ({
|
||||
serverId,
|
||||
name: `name-of-${serverId}`,
|
||||
organizationId: "org-a",
|
||||
sshKeyId: "key-1",
|
||||
}));
|
||||
mocks.setWebServerProviderIds.mockResolvedValue({ id: "ws-1" });
|
||||
mocks.setServerProviderIds.mockResolvedValue({ serverId: "server-1" });
|
||||
});
|
||||
|
||||
describe("applyVectorAgentSelection on the local host", () => {
|
||||
it("rejects the local host in cloud without touching the database", async () => {
|
||||
const { applyVectorAgentSelection } = await loadAgent(true);
|
||||
|
||||
await expect(
|
||||
applyVectorAgentSelection(sessionA, undefined, ["lp-1"]),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(mocks.setWebServerProviderIds).not.toHaveBeenCalled();
|
||||
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects with CONFLICT when another organization owns the local agent, without reconciling", async () => {
|
||||
mocks.setWebServerProviderIds.mockResolvedValue(null);
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
applyVectorAgentSelection(sessionB, undefined, ["mp-1"]),
|
||||
).rejects.toMatchObject({ code: "CONFLICT" });
|
||||
expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-b", [
|
||||
"mp-1",
|
||||
]);
|
||||
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves the selection and then reconciles", async () => {
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await applyVectorAgentSelection(sessionA, undefined, ["lp-1"]);
|
||||
expect(mocks.assertProvidersBelongToOrg).toHaveBeenCalledWith(
|
||||
["lp-1"],
|
||||
"org-a",
|
||||
);
|
||||
expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-a", [
|
||||
"lp-1",
|
||||
]);
|
||||
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith(undefined);
|
||||
expect(
|
||||
mocks.setWebServerProviderIds.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(mocks.reconcileVectorAgent.mock.invocationCallOrder[0] ?? 0);
|
||||
});
|
||||
|
||||
it("removing the agent saves an empty selection without checking providers", async () => {
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await applyVectorAgentSelection(sessionA, undefined, []);
|
||||
expect(mocks.assertProvidersBelongToOrg).not.toHaveBeenCalled();
|
||||
expect(mocks.setWebServerProviderIds).toHaveBeenCalledWith("org-a", []);
|
||||
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyVectorAgentSelection on a remote server", () => {
|
||||
it("still manages remote servers in cloud", async () => {
|
||||
const { applyVectorAgentSelection } = await loadAgent(true);
|
||||
|
||||
const result = await applyVectorAgentSelection(sessionA, "server-1", []);
|
||||
expect(result).toEqual({ serverName: "name-of-server-1" });
|
||||
expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", []);
|
||||
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith("server-1");
|
||||
});
|
||||
|
||||
it("rejects a server of another organization", async () => {
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
serverId: "server-1",
|
||||
organizationId: "org-b",
|
||||
sshKeyId: "key-1",
|
||||
});
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.setServerProviderIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a server without an SSH key before saving anything", async () => {
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
serverId: "server-1",
|
||||
organizationId: "org-a",
|
||||
sshKeyId: null,
|
||||
});
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(mocks.setServerProviderIds).not.toHaveBeenCalled();
|
||||
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a server the member cannot access", async () => {
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
applyVectorAgentSelection(sessionA, "server-2", ["lp-1"]),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.getAccessibleServerIds).toHaveBeenCalledWith(sessionA);
|
||||
expect(mocks.setServerProviderIds).not.toHaveBeenCalled();
|
||||
expect(mocks.reconcileVectorAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves the array and only then reconciles", async () => {
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await applyVectorAgentSelection(sessionA, "server-1", ["mp-1"]);
|
||||
expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", [
|
||||
"mp-1",
|
||||
]);
|
||||
expect(mocks.setServerProviderIds.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.reconcileVectorAgent.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the saved selection when the reconcile fails", async () => {
|
||||
mocks.reconcileVectorAgent.mockRejectedValue(
|
||||
new Error("validation failed"),
|
||||
);
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
applyVectorAgentSelection(sessionA, "server-1", ["lp-2"]),
|
||||
).rejects.toThrow("validation failed");
|
||||
expect(mocks.setServerProviderIds).toHaveBeenCalledWith("server-1", [
|
||||
"lp-2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("serializes concurrent selections on the same host so the last reconcile sees the last selection", async () => {
|
||||
const saved: string[][] = [];
|
||||
const seen: string[][] = [];
|
||||
mocks.setServerProviderIds.mockImplementation(
|
||||
async (_serverId: string, ids: string[]) => {
|
||||
saved.push(ids);
|
||||
return { serverId: "server-1" };
|
||||
},
|
||||
);
|
||||
mocks.reconcileVectorAgent.mockImplementation(async () => {
|
||||
seen.push(saved.at(-1) ?? []);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
const { applyVectorAgentSelection } = await loadAgent(false);
|
||||
|
||||
await Promise.all([
|
||||
applyVectorAgentSelection(sessionA, "server-1", ["lp-1"]),
|
||||
applyVectorAgentSelection(sessionA, "server-1", ["lp-2"]),
|
||||
]);
|
||||
expect(seen).toEqual([["lp-1"], ["lp-2"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeServerVectorAgent", () => {
|
||||
it("removes the agent on server deletion when providers are assigned", async () => {
|
||||
const { removeServerVectorAgent } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
removeServerVectorAgent({
|
||||
serverId: "server-1",
|
||||
sshKeyId: "key-1",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
|
||||
});
|
||||
|
||||
it("attempts cleanup even with an empty selection, since a failed Remove can leave the agent running", async () => {
|
||||
const { removeServerVectorAgent } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
removeServerVectorAgent({
|
||||
serverId: "server-1",
|
||||
sshKeyId: "key-1",
|
||||
telemetryProviderIds: [],
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mocks.removeVectorAgent).toHaveBeenCalledWith("server-1");
|
||||
});
|
||||
|
||||
it("skips servers without an SSH key, warning when providers were assigned", async () => {
|
||||
const { removeServerVectorAgent } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
removeServerVectorAgent({
|
||||
serverId: "server-1",
|
||||
sshKeyId: null,
|
||||
telemetryProviderIds: [],
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
removeServerVectorAgent({
|
||||
serverId: "server-1",
|
||||
sshKeyId: null,
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
} as never),
|
||||
).resolves.toMatch(/no SSH key/);
|
||||
expect(mocks.removeVectorAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the failure as a message instead of throwing", async () => {
|
||||
mocks.removeVectorAgent.mockRejectedValue(new Error("docker down"));
|
||||
const { removeServerVectorAgent } = await loadAgent(false);
|
||||
|
||||
await expect(
|
||||
removeServerVectorAgent({
|
||||
serverId: "server-1",
|
||||
sshKeyId: "k",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
} as never),
|
||||
).resolves.toBe("docker down");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileVectorTargets", () => {
|
||||
it("re-applies every target under its lock and reports failures as a warning", async () => {
|
||||
mocks.reconcileVectorAgent.mockImplementation(async (serverId?: string) => {
|
||||
if (serverId === "server-2") throw new Error("ssh down");
|
||||
});
|
||||
const { reconcileVectorTargets } = await loadAgent(false);
|
||||
|
||||
const warning = await reconcileVectorTargets([
|
||||
"server-1",
|
||||
"server-2",
|
||||
null,
|
||||
]);
|
||||
expect(mocks.reconcileVectorAgent.mock.calls.map((c) => c[0])).toEqual([
|
||||
"server-1",
|
||||
"server-2",
|
||||
undefined,
|
||||
]);
|
||||
expect(warning).toBe(
|
||||
"The Vector agent could not be re-applied on server-2: ssh down",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns no warning when every target reconciles, and skips the local host in cloud", async () => {
|
||||
const { reconcileVectorTargets } = await loadAgent(true);
|
||||
|
||||
await expect(
|
||||
reconcileVectorTargets(["server-1", null]),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mocks.reconcileVectorAgent).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.reconcileVectorAgent).toHaveBeenCalledWith("server-1");
|
||||
});
|
||||
});
|
||||
@@ -1,375 +0,0 @@
|
||||
import { vrlString } from "@dokploy/server/services/logs-and-metrics/types";
|
||||
import {
|
||||
buildMetricsScopeTransformSource,
|
||||
buildVectorConfigYaml,
|
||||
collectSecretValues,
|
||||
redactSecrets,
|
||||
} from "@dokploy/server/setup/vector-setup";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const provider = (overrides: Record<string, unknown>) =>
|
||||
({
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
endpoint: null,
|
||||
extraConfig: null,
|
||||
signals: ["logs"],
|
||||
...overrides,
|
||||
}) as any;
|
||||
|
||||
const loki = provider({
|
||||
telemetryProviderId: "loki-1",
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
});
|
||||
|
||||
const prom = provider({
|
||||
telemetryProviderId: "prom-1",
|
||||
name: "prom",
|
||||
providerType: "prometheus_remote_write",
|
||||
signals: ["metrics"],
|
||||
endpoint: "http://prom.example.com:9090/api/v1/write",
|
||||
});
|
||||
|
||||
const datadog = provider({
|
||||
telemetryProviderId: "dd-1",
|
||||
name: "datadog",
|
||||
providerType: "datadog",
|
||||
signals: ["logs", "metrics"],
|
||||
apiKey: "dd-key",
|
||||
});
|
||||
|
||||
const build = (
|
||||
overrides: Partial<Parameters<typeof buildVectorConfigYaml>[0]>,
|
||||
) =>
|
||||
parse(
|
||||
buildVectorConfigYaml({
|
||||
logProviders: [],
|
||||
metricsProviders: [],
|
||||
serverName: "my server",
|
||||
...overrides,
|
||||
}),
|
||||
) as any;
|
||||
|
||||
describe("buildVectorConfigYaml — logs", () => {
|
||||
it("generates a source, a dokploy_scope transform and one sink per provider, and nothing for metrics", () => {
|
||||
const config = build({ logProviders: [loki] });
|
||||
|
||||
expect(config.data_dir).toBe("/var/lib/vector");
|
||||
expect(config.sources.docker_logs_source.type).toBe("docker_logs");
|
||||
expect(config.transforms.dokploy_scope.type).toBe("remap");
|
||||
expect(config.transforms.dokploy_scope.inputs).toEqual([
|
||||
"docker_logs_source",
|
||||
]);
|
||||
expect(Object.keys(config.sinks)).toEqual(["sink_loki-1"]);
|
||||
expect(config.sinks["sink_loki-1"].type).toBe("loki");
|
||||
expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]);
|
||||
|
||||
expect(config.sources.host_metrics_source).toBeUndefined();
|
||||
expect(config.sources.cadvisor_source).toBeUndefined();
|
||||
expect(config.transforms.dokploy_metrics_scope).toBeUndefined();
|
||||
});
|
||||
|
||||
it("chains scope -> transform -> sink for betterstack/datadog, and scope -> sink direct for loki", () => {
|
||||
const config = build({
|
||||
logProviders: [
|
||||
loki,
|
||||
provider({
|
||||
telemetryProviderId: "bs-1",
|
||||
name: "betterstack",
|
||||
providerType: "betterstack",
|
||||
endpoint: "https://in.logs.betterstack.com",
|
||||
apiKey: "token",
|
||||
}),
|
||||
datadog,
|
||||
],
|
||||
});
|
||||
|
||||
expect(config.sinks["sink_loki-1"].inputs).toEqual(["dokploy_scope"]);
|
||||
expect(config.transforms["transform_bs-1"].inputs).toEqual([
|
||||
"dokploy_scope",
|
||||
]);
|
||||
expect(config.sinks["sink_bs-1"].inputs).toEqual(["transform_bs-1"]);
|
||||
expect(config.transforms["transform_dd-1"].inputs).toEqual([
|
||||
"dokploy_scope",
|
||||
]);
|
||||
expect(config.sinks["sink_dd-1"].inputs).toEqual(["transform_dd-1"]);
|
||||
});
|
||||
|
||||
it("tags every log with the server name and reads the Dokploy metadata straight off the container labels, with empty defaults for containers Dokploy did not deploy", () => {
|
||||
const config = build({ logProviders: [loki], serverName: 'srv "quoted"' });
|
||||
|
||||
expect(config.transforms.dokploy_scope.source).toBe(
|
||||
[
|
||||
'.dokploy_server = "srv \\"quoted\\""',
|
||||
'.dokploy_organization = .label."dokploy.organization.id" || ""',
|
||||
'.dokploy_project = .label."dokploy.project" || ""',
|
||||
'.dokploy_project_id = .label."dokploy.project.id" || ""',
|
||||
'.dokploy_environment = .label."dokploy.environment" || ""',
|
||||
'.dokploy_environment_id = .label."dokploy.environment.id" || ""',
|
||||
'.dokploy_application = .label."dokploy.application" || ""',
|
||||
'.dokploy_application_id = .label."dokploy.application.id" || ""',
|
||||
'.dokploy_service = .label."dokploy.service" || ""',
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a provider whose config an adapter rejects at build time, instead of failing the whole org's config", () => {
|
||||
const config = build({
|
||||
logProviders: [
|
||||
provider({
|
||||
telemetryProviderId: "broken-es",
|
||||
name: "es-broken",
|
||||
providerType: "elasticsearch",
|
||||
endpoint: "https://es.example.com:9200",
|
||||
extraConfig: { username: "elastic" },
|
||||
}),
|
||||
loki,
|
||||
],
|
||||
});
|
||||
|
||||
expect(Object.keys(config.sinks)).toEqual(["sink_loki-1"]);
|
||||
});
|
||||
|
||||
it("only keeps containers of the owning organization on the local host, which is shared by every organization", () => {
|
||||
const config = build({ logProviders: [loki], organizationId: "org-a" });
|
||||
|
||||
expect(config.transforms.dokploy_scope_local_only).toEqual({
|
||||
type: "filter",
|
||||
inputs: ["dokploy_scope"],
|
||||
condition: '.dokploy_organization == "org-a"',
|
||||
});
|
||||
expect(config.sinks["sink_loki-1"].inputs).toEqual([
|
||||
"dokploy_scope_local_only",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not add the organization filter for the per-server (non-local) path", () => {
|
||||
const config = build({ logProviders: [loki] });
|
||||
|
||||
expect(config.transforms.dokploy_scope_local_only).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildVectorConfigYaml — metrics", () => {
|
||||
it("with only metrics providers it emits the host and cAdvisor sources and no logs pipeline", () => {
|
||||
const config = build({ metricsProviders: [prom] });
|
||||
|
||||
expect(config.sources.docker_logs_source).toBeUndefined();
|
||||
expect(config.transforms.dokploy_scope).toBeUndefined();
|
||||
expect(config.sources.host_metrics_source).toMatchObject({
|
||||
type: "host_metrics",
|
||||
scrape_interval_secs: 30,
|
||||
collectors: [
|
||||
"cpu",
|
||||
"memory",
|
||||
"disk",
|
||||
"filesystem",
|
||||
"network",
|
||||
"load",
|
||||
"host",
|
||||
],
|
||||
});
|
||||
expect(
|
||||
config.sources.host_metrics_source.filesystem.filesystems.excludes,
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
"overlay",
|
||||
"tmpfs",
|
||||
"squashfs",
|
||||
"devtmpfs",
|
||||
"nsfs",
|
||||
"proc",
|
||||
"sysfs",
|
||||
"devpts",
|
||||
"cgroup2",
|
||||
]),
|
||||
);
|
||||
expect(config.sources.host_metrics_source.network.devices.excludes).toEqual(
|
||||
["veth*", "br-*", "docker*"],
|
||||
);
|
||||
expect(config.sources.cadvisor_source).toEqual({
|
||||
type: "prometheus_scrape",
|
||||
endpoints: ["http://127.0.0.1:4510/metrics"],
|
||||
scrape_interval_secs: 30,
|
||||
scrape_timeout_secs: 10,
|
||||
});
|
||||
expect(config.transforms.dokploy_metrics_scope).toMatchObject({
|
||||
type: "remap",
|
||||
inputs: ["host_metrics_source", "cadvisor_source"],
|
||||
drop_on_abort: true,
|
||||
});
|
||||
expect(Object.keys(config.sinks)).toEqual(["sink_prom-1_metrics"]);
|
||||
expect(config.sinks["sink_prom-1_metrics"]).toMatchObject({
|
||||
type: "prometheus_remote_write",
|
||||
inputs: ["dokploy_metrics_scope"],
|
||||
});
|
||||
expect(config.transforms.dokploy_metrics_scope_local_only).toBeUndefined();
|
||||
});
|
||||
|
||||
it("on the local host it lets host series through and keeps only the owning organization's containers", () => {
|
||||
const config = build({ metricsProviders: [prom], organizationId: "org-a" });
|
||||
|
||||
expect(config.transforms.dokploy_metrics_scope_local_only).toEqual({
|
||||
type: "filter",
|
||||
inputs: ["dokploy_metrics_scope"],
|
||||
condition:
|
||||
'.namespace == "host" || .tags.dokploy_organization == "org-a"',
|
||||
});
|
||||
expect(config.sinks["sink_prom-1_metrics"].inputs).toEqual([
|
||||
"dokploy_metrics_scope_local_only",
|
||||
]);
|
||||
});
|
||||
|
||||
it("a provider assigned to both signals gets a logs sink and a metrics sink without clashing", () => {
|
||||
const config = build({
|
||||
logProviders: [datadog],
|
||||
metricsProviders: [datadog],
|
||||
organizationId: "org-a",
|
||||
});
|
||||
|
||||
expect(config.sinks["sink_dd-1"]).toMatchObject({
|
||||
type: "datadog_logs",
|
||||
inputs: ["transform_dd-1"],
|
||||
});
|
||||
expect(config.sinks["sink_dd-1_metrics"]).toMatchObject({
|
||||
type: "datadog_metrics",
|
||||
inputs: ["dokploy_metrics_scope_local_only"],
|
||||
});
|
||||
expect(config.transforms["transform_dd-1_metrics"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tags every series with the server name, promotes non-empty dokploy labels and drops the cgroup id", () => {
|
||||
const source = buildMetricsScopeTransformSource('srv "quoted"');
|
||||
|
||||
expect(source).toContain('.tags.dokploy_server = "srv \\"quoted\\""');
|
||||
expect(source).toContain(
|
||||
'if .namespace != "host" && !starts_with(string!(.name), "container_") { abort }',
|
||||
);
|
||||
expect(source).toContain(
|
||||
'value = del(.tags.container_label_dokploy_application)\nif is_string(value) && value != "" { .tags.dokploy_application = value }',
|
||||
);
|
||||
expect(source.trim().endsWith("del(.tags.id)")).toBe(true);
|
||||
expect(
|
||||
build({ metricsProviders: [prom] }).transforms.dokploy_metrics_scope
|
||||
.source,
|
||||
).toBe(buildMetricsScopeTransformSource("my server"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildVectorConfigYaml — dollar signs", () => {
|
||||
it("keeps every $ literal because the agent runs without env var interpolation", () => {
|
||||
const yamlStr = buildVectorConfigYaml({
|
||||
logProviders: [
|
||||
provider({
|
||||
telemetryProviderId: "loki-1",
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: "Pa$5w0rd$HOME",
|
||||
extraConfig: { username: "12345" },
|
||||
}),
|
||||
],
|
||||
metricsProviders: [prom],
|
||||
serverName: "srv $ONE",
|
||||
});
|
||||
|
||||
expect(yamlStr).not.toContain("$$");
|
||||
expect(yamlStr).toContain("srv $ONE");
|
||||
expect(parse(yamlStr).sinks["sink_loki-1"].auth.password).toBe(
|
||||
"Pa$5w0rd$HOME",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectSecretValues / redactSecrets", () => {
|
||||
it("collects endpoint/apiKey/apiSecret across all providers but only the password-type extraConfig fields", () => {
|
||||
const providers = [
|
||||
{
|
||||
providerType: "loki",
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: "loki-key",
|
||||
apiSecret: null,
|
||||
extraConfig: { tenantId: "tenant-a", username: "12345" },
|
||||
},
|
||||
{
|
||||
providerType: "datadog",
|
||||
endpoint: null,
|
||||
apiKey: "dd-key",
|
||||
apiSecret: null,
|
||||
extraConfig: { site: "datadoghq.eu" },
|
||||
},
|
||||
{
|
||||
providerType: "aws_cloudwatch",
|
||||
endpoint: null,
|
||||
apiKey: "AKIA",
|
||||
apiSecret: "shh",
|
||||
extraConfig: { region: "us-east-1", metricsNamespace: "Dokploy" },
|
||||
},
|
||||
] as any;
|
||||
|
||||
const values = collectSecretValues(providers);
|
||||
expect(values).toEqual(
|
||||
expect.arrayContaining([
|
||||
"https://loki.example.com",
|
||||
"loki-key",
|
||||
"dd-key",
|
||||
"AKIA",
|
||||
"shh",
|
||||
]),
|
||||
);
|
||||
for (const plain of [
|
||||
"tenant-a",
|
||||
"12345",
|
||||
"datadoghq.eu",
|
||||
"us-east-1",
|
||||
"Dokploy",
|
||||
]) {
|
||||
expect(values).not.toContain(plain);
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores null/empty credentials", () => {
|
||||
const providers = [
|
||||
{
|
||||
providerType: "elasticsearch",
|
||||
endpoint: null,
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
extraConfig: { limit: 10, enabled: true, empty: "" },
|
||||
},
|
||||
] as any;
|
||||
expect(collectSecretValues(providers)).toEqual([]);
|
||||
});
|
||||
|
||||
it("redacts every collected secret, longest first so a short one can't eat part of a longer one", () => {
|
||||
const providers = [
|
||||
{
|
||||
providerType: "loki",
|
||||
endpoint: null,
|
||||
apiKey: "hunter2-long-suffix",
|
||||
apiSecret: "hunter2",
|
||||
extraConfig: null,
|
||||
},
|
||||
] as any;
|
||||
const text =
|
||||
"sink validation error near apiKey=hunter2-long-suffix and hunter2";
|
||||
|
||||
const redacted = redactSecrets(text, collectSecretValues(providers));
|
||||
|
||||
expect(redacted).not.toContain("hunter2");
|
||||
expect(redacted).toBe(
|
||||
"sink validation error near apiKey=[redacted] and [redacted]",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vrlString", () => {
|
||||
it("quotes and escapes like JSON but drops the control characters VRL rejects", () => {
|
||||
expect(vrlString('my "server"')).toBe('"my \\"server\\""');
|
||||
expect(vrlString("back\\slash")).toBe('"back\\\\slash"');
|
||||
expect(vrlString("a\u0008b\u000cc\u0000d\u007fe\nf")).toBe('"abcdef"');
|
||||
});
|
||||
});
|
||||
@@ -1,526 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findServerById: vi.fn(),
|
||||
getWebServerSettings: vi.fn(),
|
||||
findEnabledTelemetryProvidersByOrganization: vi.fn(),
|
||||
execAsync: vi.fn(),
|
||||
execAsyncRemote: vi.fn(),
|
||||
inspectService: vi.fn(),
|
||||
updateService: vi.fn(),
|
||||
removeService: vi.fn(),
|
||||
createService: vi.fn(),
|
||||
getService: vi.fn(),
|
||||
listContainers: vi.fn(),
|
||||
killContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({ db: {} }));
|
||||
vi.mock("@dokploy/server/constants", () => ({
|
||||
IS_CLOUD: false,
|
||||
paths: (isServer: boolean) => ({
|
||||
VECTOR_PATH: isServer ? "/etc/dokploy/vector" : "/local/dokploy/vector",
|
||||
}),
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServerById: mocks.findServerById,
|
||||
findServersByOrganizationForVectorAgent: vi.fn(),
|
||||
getAccessibleServerIds: vi.fn(),
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/web-server-settings", () => ({
|
||||
getWebServerSettings: mocks.getWebServerSettings,
|
||||
}));
|
||||
vi.mock(
|
||||
"@dokploy/server/services/logs-and-metrics/service",
|
||||
async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("@dokploy/server/services/logs-and-metrics/service")
|
||||
>()),
|
||||
findEnabledTelemetryProvidersByOrganization:
|
||||
mocks.findEnabledTelemetryProvidersByOrganization,
|
||||
}),
|
||||
);
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsync: mocks.execAsync,
|
||||
execAsyncRemote: mocks.execAsyncRemote,
|
||||
}));
|
||||
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
|
||||
getRemoteDocker: async () => ({
|
||||
getService: mocks.getService,
|
||||
createService: mocks.createService,
|
||||
listContainers: mocks.listContainers,
|
||||
getContainer: () => ({ kill: mocks.killContainer }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { reconcileVectorAgent, removeVectorAgent } = await import(
|
||||
"@dokploy/server/setup/vector-setup"
|
||||
);
|
||||
|
||||
const lokiProvider = {
|
||||
telemetryProviderId: "lp-1",
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "https://loki.example.com",
|
||||
apiKey: "sekret-token",
|
||||
apiSecret: null,
|
||||
extraConfig: null,
|
||||
enabled: true,
|
||||
organizationId: "org-a",
|
||||
};
|
||||
|
||||
const promProvider = {
|
||||
...lokiProvider,
|
||||
telemetryProviderId: "mp-1",
|
||||
name: "prom",
|
||||
providerType: "prometheus_remote_write",
|
||||
signals: ["metrics"],
|
||||
endpoint: "http://prom.example.com:9090/api/v1/write",
|
||||
apiKey: null,
|
||||
};
|
||||
|
||||
const notFound = () =>
|
||||
Object.assign(new Error("not found"), { statusCode: 404 });
|
||||
|
||||
const writtenYaml = (command: string) => {
|
||||
const match = command.match(/echo "([^"]+)" \| base64 -d/);
|
||||
return parse(Buffer.from(match?.[1] ?? "", "base64").toString("utf8"));
|
||||
};
|
||||
|
||||
const commandsOf = (mock: typeof mocks.execAsync) =>
|
||||
mock.mock.calls.map((call) => String(call[call.length - 1]));
|
||||
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||
mocks.getService.mockImplementation(() => ({
|
||||
inspect: mocks.inspectService,
|
||||
update: mocks.updateService,
|
||||
remove: mocks.removeService,
|
||||
}));
|
||||
mocks.inspectService.mockRejectedValue(notFound());
|
||||
mocks.removeService.mockRejectedValue(notFound());
|
||||
mocks.execAsync.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
mocks.listContainers.mockResolvedValue([{ Id: "vector-container" }]);
|
||||
mocks.killContainer.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("reconcileVectorAgent", () => {
|
||||
it("writes, validates and applies the YAML and deploys Vector for the saved selection", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
const commands = commandsOf(mocks.execAsync);
|
||||
expect(commands).toHaveLength(3);
|
||||
expect(commands[0]).toContain(
|
||||
"/local/dokploy/vector/vector.yaml.candidate.",
|
||||
);
|
||||
expect(commands[1]).toMatch(
|
||||
/timberio\/vector:\d+\.\d+\.\d+-alpine validate --skip-healthchecks/,
|
||||
);
|
||||
expect(commands[2]).toMatch(
|
||||
/^mv \/local\/dokploy\/vector\/vector\.yaml\.candidate\.\S+ \/local\/dokploy\/vector\/vector\.yaml$/,
|
||||
);
|
||||
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
|
||||
|
||||
const config = writtenYaml(commands[0] ?? "");
|
||||
expect(config.sinks["sink_lp-1"].inputs).toEqual([
|
||||
"dokploy_scope_local_only",
|
||||
]);
|
||||
expect(config.sources.host_metrics_source).toBeUndefined();
|
||||
|
||||
expect(mocks.createService).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.createService.mock.calls[0]?.[0]).toMatchObject({
|
||||
Name: "dokploy-vector",
|
||||
});
|
||||
expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor");
|
||||
expect(mocks.removeService).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("splits one selection into the logs and metrics pipelines by what each provider sends", async () => {
|
||||
const datadogProvider = {
|
||||
...lokiProvider,
|
||||
telemetryProviderId: "dd-1",
|
||||
name: "datadog",
|
||||
providerType: "datadog",
|
||||
signals: ["logs", "metrics"],
|
||||
endpoint: null,
|
||||
apiKey: "dd-key",
|
||||
};
|
||||
const unselected = { ...lokiProvider, telemetryProviderId: "lp-9" };
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1", "mp-1", "dd-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
promProvider,
|
||||
datadogProvider,
|
||||
unselected,
|
||||
]);
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? "");
|
||||
const sinkIds = Object.keys(config.sinks);
|
||||
expect(sinkIds.some((id) => id.includes("lp-1"))).toBe(true);
|
||||
expect(sinkIds.some((id) => id.includes("mp-1"))).toBe(true);
|
||||
expect(sinkIds.filter((id) => id.includes("dd-1")).length).toBe(2);
|
||||
expect(sinkIds.some((id) => id.includes("lp-9"))).toBe(false);
|
||||
expect(config.sources.docker_logs_source).toBeDefined();
|
||||
expect(config.sources.cadvisor_source).toBeDefined();
|
||||
expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([
|
||||
"dokploy-vector",
|
||||
"dokploy-cadvisor",
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes cAdvisor but keeps Vector when no selected provider sends metrics", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1", "mp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? "");
|
||||
expect(config.sources.cadvisor_source).toBeUndefined();
|
||||
expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([
|
||||
"dokploy-vector",
|
||||
]);
|
||||
expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor");
|
||||
expect(mocks.removeService).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("filters by the organization only on the local host and runs on the server over SSH", async () => {
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
serverId: "server-1",
|
||||
organizationId: "org-a",
|
||||
name: "edge-1",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
|
||||
await reconcileVectorAgent("server-1");
|
||||
|
||||
expect(mocks.execAsync).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mocks.execAsyncRemote.mock.calls.every((c) => c[0] === "server-1"),
|
||||
).toBe(true);
|
||||
const config = writtenYaml(commandsOf(mocks.execAsyncRemote)[0] ?? "");
|
||||
expect(config.transforms.dokploy_scope_local_only).toBeUndefined();
|
||||
expect(config.sinks["sink_lp-1"].inputs).toEqual(["dokploy_scope"]);
|
||||
});
|
||||
|
||||
it("does not apply a config that vector validate rejects, redacts secrets and leaves the selection alone", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
mocks.execAsync.mockImplementation(async (command: string) => {
|
||||
if (command.includes("validate")) {
|
||||
throw new Error("bad sink: password sekret-token rejected");
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
});
|
||||
|
||||
await expect(reconcileVectorAgent()).rejects.toThrow(
|
||||
/failed validation, not applying it: bad sink: password \[redacted\] rejected/,
|
||||
);
|
||||
|
||||
const commands = commandsOf(mocks.execAsync);
|
||||
expect(commands.some((c) => c.startsWith("mv "))).toBe(false);
|
||||
expect(commands.some((c) => c.startsWith("rm -f "))).toBe(true);
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the agent when no enabled provider is left in the selection", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["deleted-or-disabled"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([]);
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
expect(mocks.getService).toHaveBeenCalledWith("dokploy-cadvisor");
|
||||
expect(mocks.getService).toHaveBeenCalledWith("dokploy-vector");
|
||||
expect(mocks.removeService).toHaveBeenCalledTimes(2);
|
||||
expect(commandsOf(mocks.execAsync)).toEqual([
|
||||
"rm -rf /local/dokploy/vector",
|
||||
]);
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the agent when the selection is empty, without an owner", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: null,
|
||||
telemetryProviderIds: [],
|
||||
});
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
expect(
|
||||
mocks.findEnabledTelemetryProvidersByOrganization,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(mocks.removeService).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("deploys cAdvisor next to Vector when metrics providers are assigned", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["mp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
promProvider,
|
||||
]);
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
const config = writtenYaml(commandsOf(mocks.execAsync)[0] ?? "");
|
||||
expect(config.sources.docker_logs_source).toBeUndefined();
|
||||
expect(config.sources.cadvisor_source.endpoints).toEqual([
|
||||
"http://127.0.0.1:4510/metrics",
|
||||
]);
|
||||
|
||||
expect(mocks.createService.mock.calls.map((c) => c[0].Name)).toEqual([
|
||||
"dokploy-vector",
|
||||
"dokploy-cadvisor",
|
||||
]);
|
||||
const cadvisor = mocks.createService.mock.calls[1]?.[0];
|
||||
expect(cadvisor.TaskTemplate.ContainerSpec.Image).toMatch(
|
||||
/^ghcr\.io\/google\/cadvisor:v\d+\.\d+\.\d+$/,
|
||||
);
|
||||
expect(cadvisor.TaskTemplate.ContainerSpec.Args).toEqual(
|
||||
expect.arrayContaining([
|
||||
"--listen_ip=127.0.0.1",
|
||||
"--port=4510",
|
||||
"--docker_only=true",
|
||||
"--store_container_labels=false",
|
||||
]),
|
||||
);
|
||||
expect(cadvisor.TaskTemplate.ContainerSpec.HealthCheck).toEqual({
|
||||
Test: ["NONE"],
|
||||
});
|
||||
expect(cadvisor.TaskTemplate.Networks).toEqual([{ Target: "host" }]);
|
||||
expect(mocks.removeService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the agent on a remote server when the selection is empty", async () => {
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
serverId: "server-1",
|
||||
sshKeyId: "key-1",
|
||||
organizationId: "org-a",
|
||||
name: "edge-1",
|
||||
telemetryProviderIds: [],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([]);
|
||||
|
||||
await reconcileVectorAgent("server-1");
|
||||
|
||||
expect(
|
||||
mocks.findEnabledTelemetryProvidersByOrganization,
|
||||
).toHaveBeenCalled();
|
||||
expect(mocks.removeService).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
expect(commandsOf(mocks.execAsyncRemote)).toEqual([
|
||||
"rm -rf /etc/dokploy/vector",
|
||||
]);
|
||||
});
|
||||
|
||||
it("redacts the config and secrets when writing or applying the candidate fails", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
const echoCommand = async (command: string) => {
|
||||
if (command.includes("base64 -d") || command.startsWith("mv ")) {
|
||||
throw new Error(`Command failed: ${command} (token sekret-token)`);
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
};
|
||||
mocks.execAsync.mockImplementation(echoCommand);
|
||||
|
||||
const writeError = await reconcileVectorAgent().catch(
|
||||
(e: Error) => e.message,
|
||||
);
|
||||
expect(writeError).toMatch(/^Failed to write the Vector config: /);
|
||||
expect(writeError).not.toContain("sekret-token");
|
||||
expect(writeError).not.toContain(
|
||||
Buffer.from("data_dir").toString("base64").slice(0, 8),
|
||||
);
|
||||
expect(writeError).toContain("[redacted]");
|
||||
|
||||
mocks.execAsync.mockImplementation(async (command: string) => {
|
||||
if (command.startsWith("mv ")) return echoCommand(command);
|
||||
return { stdout: "", stderr: "" };
|
||||
});
|
||||
const applyError = await reconcileVectorAgent().catch(
|
||||
(e: Error) => e.message,
|
||||
);
|
||||
expect(applyError).toMatch(/^Failed to apply the Vector config: /);
|
||||
expect(applyError).not.toContain("sekret-token");
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates a rejected service update instead of pretending the create succeeded", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
mocks.inspectService.mockResolvedValue({
|
||||
Version: { Index: "7" },
|
||||
Spec: { TaskTemplate: { ForceUpdate: 2 } },
|
||||
});
|
||||
mocks.updateService.mockRejectedValue(
|
||||
new Error("rpc error: invalid mount"),
|
||||
);
|
||||
|
||||
await expect(reconcileVectorAgent()).rejects.toThrow("invalid mount");
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates an inspect failure that is not a 404", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
mocks.inspectService.mockRejectedValue(
|
||||
Object.assign(new Error("daemon unreachable"), { statusCode: 500 }),
|
||||
);
|
||||
|
||||
await expect(reconcileVectorAgent()).rejects.toThrow("daemon unreachable");
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not restart Vector on a config-only redeploy", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
mocks.inspectService.mockImplementation(async () => ({
|
||||
Version: { Index: "7" },
|
||||
Spec: {
|
||||
TaskTemplate: {
|
||||
ForceUpdate: 2,
|
||||
ContainerSpec: mocks.createService.mock.calls[0]?.[0]
|
||||
? undefined
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
await reconcileVectorAgent();
|
||||
expect(mocks.killContainer).toHaveBeenCalledWith({ signal: "SIGHUP" });
|
||||
expect(mocks.listContainers).toHaveBeenCalledWith({
|
||||
filters: JSON.stringify({
|
||||
label: ["com.docker.swarm.service.name=dokploy-vector"],
|
||||
}),
|
||||
});
|
||||
const firstSpec = mocks.updateService.mock.calls[0]?.[0];
|
||||
mocks.inspectService.mockResolvedValue({
|
||||
Version: { Index: "8" },
|
||||
Spec: { TaskTemplate: { ...firstSpec.TaskTemplate, ForceUpdate: 3 } },
|
||||
});
|
||||
mocks.updateService.mockClear();
|
||||
|
||||
await reconcileVectorAgent();
|
||||
expect(mocks.updateService).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
version: 8,
|
||||
TaskTemplate: expect.objectContaining({ ForceUpdate: 3 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates an existing Vector service instead of creating it", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
mocks.findEnabledTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
lokiProvider,
|
||||
]);
|
||||
mocks.inspectService.mockResolvedValue({
|
||||
Version: { Index: "7" },
|
||||
Spec: { TaskTemplate: { ForceUpdate: 2 } },
|
||||
});
|
||||
|
||||
await reconcileVectorAgent();
|
||||
|
||||
expect(mocks.createService).not.toHaveBeenCalled();
|
||||
expect(mocks.updateService).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
version: 7,
|
||||
TaskTemplate: expect.objectContaining({ ForceUpdate: 3 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeVectorAgent", () => {
|
||||
it("removes cAdvisor, then Vector, then the config directory", async () => {
|
||||
await removeVectorAgent();
|
||||
|
||||
expect(mocks.getService.mock.calls.map((c) => c[0])).toEqual([
|
||||
"dokploy-cadvisor",
|
||||
"dokploy-vector",
|
||||
]);
|
||||
expect(commandsOf(mocks.execAsync)).toEqual([
|
||||
"rm -rf /local/dokploy/vector",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps going when cAdvisor cannot be removed", async () => {
|
||||
mocks.removeService
|
||||
.mockRejectedValueOnce(new Error("docker hiccup"))
|
||||
.mockRejectedValueOnce(notFound());
|
||||
|
||||
await removeVectorAgent();
|
||||
|
||||
expect(mocks.removeService).toHaveBeenCalledTimes(2);
|
||||
expect(commandsOf(mocks.execAsync)).toEqual([
|
||||
"rm -rf /local/dokploy/vector",
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses a server without an SSH key before touching cAdvisor or Vector", async () => {
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
serverId: "server-1",
|
||||
sshKeyId: null,
|
||||
});
|
||||
|
||||
await expect(removeVectorAgent("server-1")).rejects.toThrow(/SSH key/);
|
||||
expect(mocks.getService).not.toHaveBeenCalled();
|
||||
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,214 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listTasks: vi.fn(),
|
||||
inspectService: vi.fn(),
|
||||
findServersByOrganizationForVectorAgent: vi.fn(),
|
||||
getAccessibleServerIds: vi.fn(),
|
||||
getWebServerSettings: vi.fn(),
|
||||
findTelemetryProvidersByOrganization: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({ db: {} }));
|
||||
vi.mock("@dokploy/server/constants", () => ({
|
||||
IS_CLOUD: false,
|
||||
paths: () => ({ VECTOR_PATH: "/etc/dokploy/vector" }),
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServerById: vi.fn(),
|
||||
findServersByOrganizationForVectorAgent:
|
||||
mocks.findServersByOrganizationForVectorAgent,
|
||||
getAccessibleServerIds: mocks.getAccessibleServerIds,
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/web-server-settings", () => ({
|
||||
getWebServerSettings: mocks.getWebServerSettings,
|
||||
}));
|
||||
vi.mock(
|
||||
"@dokploy/server/services/logs-and-metrics/service",
|
||||
async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("@dokploy/server/services/logs-and-metrics/service")
|
||||
>()),
|
||||
findTelemetryProvidersByOrganization:
|
||||
mocks.findTelemetryProvidersByOrganization,
|
||||
}),
|
||||
);
|
||||
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
|
||||
getRemoteDocker: async () => ({
|
||||
listTasks: mocks.listTasks,
|
||||
getService: () => ({ inspect: mocks.inspectService }),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { getVectorAgentTargets, withVectorTargetLock } = await import(
|
||||
"@dokploy/server/setup/vector-setup"
|
||||
);
|
||||
|
||||
const session = { userId: "user-1", activeOrganizationId: "org-1" };
|
||||
|
||||
describe("getVectorAgentTargets", () => {
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([]);
|
||||
mocks.inspectService.mockResolvedValue({});
|
||||
mocks.getAccessibleServerIds.mockResolvedValue(new Set());
|
||||
mocks.findTelemetryProvidersByOrganization.mockResolvedValue([
|
||||
{ telemetryProviderId: "lp-1", enabled: true, signals: ["logs"] },
|
||||
{ telemetryProviderId: "mp-1", enabled: true, signals: ["metrics"] },
|
||||
{ telemetryProviderId: "mp-off", enabled: false, signals: ["metrics"] },
|
||||
]);
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-1",
|
||||
telemetryProviderIds: ["lp-1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a running cAdvisor task when the target ships metrics", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-1",
|
||||
telemetryProviderIds: ["mp-1"],
|
||||
});
|
||||
mocks.listTasks.mockImplementation(
|
||||
async ({ filters }: { filters: string }) =>
|
||||
JSON.parse(filters).service[0] === "dokploy-vector"
|
||||
? [{ Status: { State: "running" } }]
|
||||
: [],
|
||||
);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("not-running");
|
||||
expect(mocks.listTasks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports running when both Vector and cAdvisor have a running task", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-1",
|
||||
telemetryProviderIds: ["mp-1"],
|
||||
});
|
||||
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("running");
|
||||
});
|
||||
|
||||
it("does not look at cAdvisor for a logs-only target", async () => {
|
||||
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
|
||||
|
||||
await getVectorAgentTargets(session);
|
||||
expect(mocks.listTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not look at cAdvisor when the only metrics provider is disabled", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: "org-1",
|
||||
telemetryProviderIds: ["lp-1", "mp-off"],
|
||||
});
|
||||
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("running");
|
||||
expect(mocks.listTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports running only when a task of the service is running", async () => {
|
||||
mocks.listTasks.mockResolvedValue([{ Status: { State: "running" } }]);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("running");
|
||||
expect(mocks.listTasks).toHaveBeenCalledWith({
|
||||
filters: JSON.stringify({
|
||||
service: ["dokploy-vector"],
|
||||
"desired-state": ["running"],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("reports not-running when the service has no running task", async () => {
|
||||
mocks.listTasks.mockResolvedValue([{ Status: { State: "pending" } }]);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("not-running");
|
||||
});
|
||||
|
||||
it("reports stopped when the service does not exist", async () => {
|
||||
mocks.inspectService.mockRejectedValue(
|
||||
Object.assign(new Error("not found"), { statusCode: 404 }),
|
||||
);
|
||||
mocks.listTasks.mockResolvedValue([]);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("stopped");
|
||||
expect(mocks.listTasks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports unknown on any other docker error", async () => {
|
||||
mocks.inspectService.mockRejectedValue(new Error("connect ECONNREFUSED"));
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local?.status).toBe("unknown");
|
||||
});
|
||||
|
||||
it("reports an empty selection for an unowned local host, even with ids left by a deleted organization", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
vectorAgentOrganizationId: null,
|
||||
telemetryProviderIds: ["lp-from-deleted-org", "mp-from-deleted-org"],
|
||||
});
|
||||
mocks.listTasks.mockResolvedValue([]);
|
||||
|
||||
const [local] = await getVectorAgentTargets(session);
|
||||
expect(local).toMatchObject({
|
||||
serverId: null,
|
||||
telemetryProviderIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("hides servers the member cannot access", async () => {
|
||||
mocks.listTasks.mockResolvedValue([]);
|
||||
mocks.findServersByOrganizationForVectorAgent.mockResolvedValue([
|
||||
{
|
||||
serverId: "server-1",
|
||||
name: "one",
|
||||
ipAddress: "1.1.1.1",
|
||||
telemetryProviderIds: [],
|
||||
},
|
||||
{
|
||||
serverId: "server-2",
|
||||
name: "two",
|
||||
ipAddress: "2.2.2.2",
|
||||
telemetryProviderIds: [],
|
||||
},
|
||||
]);
|
||||
mocks.getAccessibleServerIds.mockResolvedValue(new Set(["server-2"]));
|
||||
|
||||
const targets = await getVectorAgentTargets(session);
|
||||
expect(targets.map((t) => t.serverId)).toEqual([null, "server-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withVectorTargetLock", () => {
|
||||
it("runs operations on the same target one after another", async () => {
|
||||
const events: string[] = [];
|
||||
let releaseFirst = () => {};
|
||||
const first = withVectorTargetLock("server-1", async () => {
|
||||
events.push("first:start");
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
events.push("first:end");
|
||||
throw new Error("first failed");
|
||||
});
|
||||
const second = withVectorTargetLock("server-1", async () => {
|
||||
events.push("second");
|
||||
});
|
||||
const other = withVectorTargetLock("server-2", async () => {
|
||||
events.push("other");
|
||||
});
|
||||
|
||||
await other;
|
||||
expect(events).toEqual(["first:start", "other"]);
|
||||
releaseFirst();
|
||||
await expect(first).rejects.toThrow("first failed");
|
||||
await second;
|
||||
expect(events).toEqual(["first:start", "other", "first:end", "second"]);
|
||||
});
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildVectorConfigYaml,
|
||||
VECTOR_IMAGE,
|
||||
} from "@dokploy/server/setup/vector-setup";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dockerAvailable = (() => {
|
||||
try {
|
||||
execFileSync("docker", ["info"], { stdio: "ignore", timeout: 10_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
const provider = (overrides: Record<string, unknown>) =>
|
||||
({
|
||||
apiKey: null,
|
||||
apiSecret: null,
|
||||
endpoint: null,
|
||||
extraConfig: null,
|
||||
enabled: true,
|
||||
...overrides,
|
||||
}) as any;
|
||||
|
||||
const logsOnly = [
|
||||
provider({
|
||||
telemetryProviderId: "loki1",
|
||||
name: "loki",
|
||||
providerType: "loki",
|
||||
signals: ["logs"],
|
||||
endpoint: "http://loki.example.com:3100",
|
||||
apiKey: "Pa$5w0rd$HOME",
|
||||
extraConfig: { username: "12345" },
|
||||
}),
|
||||
];
|
||||
|
||||
const metricsOnly = [
|
||||
provider({
|
||||
telemetryProviderId: "prom1",
|
||||
name: "prom",
|
||||
providerType: "prometheus_remote_write",
|
||||
signals: ["metrics"],
|
||||
endpoint: "http://prom.example.com:9090/api/v1/write",
|
||||
apiKey: "token",
|
||||
extraConfig: { tenantId: "team-a" },
|
||||
}),
|
||||
provider({
|
||||
telemetryProviderId: "nr1",
|
||||
name: "new relic",
|
||||
providerType: "new_relic",
|
||||
signals: ["metrics"],
|
||||
apiKey: "license",
|
||||
extraConfig: { accountId: "123", region: "eu" },
|
||||
}),
|
||||
provider({
|
||||
telemetryProviderId: "influx1",
|
||||
name: "influx",
|
||||
providerType: "influxdb",
|
||||
signals: ["metrics"],
|
||||
endpoint: "http://influx.example.com:8086",
|
||||
apiKey: "token",
|
||||
extraConfig: { org: "dokploy", bucket: "metrics" },
|
||||
}),
|
||||
];
|
||||
|
||||
const both = [
|
||||
provider({
|
||||
telemetryProviderId: "dd1",
|
||||
name: "datadog",
|
||||
providerType: "datadog",
|
||||
signals: ["logs", "metrics"],
|
||||
apiKey: "dd-key",
|
||||
}),
|
||||
provider({
|
||||
telemetryProviderId: "cw1",
|
||||
name: "cloudwatch",
|
||||
providerType: "aws_cloudwatch",
|
||||
signals: ["logs", "metrics"],
|
||||
apiKey: "AKIAFAKE",
|
||||
apiSecret: "secret",
|
||||
extraConfig: {
|
||||
region: "us-east-1",
|
||||
logGroup: "/dokploy/logs",
|
||||
metricsNamespace: "Dokploy",
|
||||
},
|
||||
}),
|
||||
provider({
|
||||
telemetryProviderId: "splunk1",
|
||||
name: "splunk",
|
||||
providerType: "splunk_hec",
|
||||
signals: ["logs", "metrics"],
|
||||
endpoint: "https://splunk.example.com:8088",
|
||||
apiKey: "hec",
|
||||
extraConfig: { index: "main", metricsIndex: "dokploy_metrics" },
|
||||
}),
|
||||
provider({
|
||||
telemetryProviderId: "es1",
|
||||
name: "elastic",
|
||||
providerType: "elasticsearch",
|
||||
signals: ["logs", "metrics"],
|
||||
endpoint: "http://es.example.com:9200",
|
||||
apiKey: "pw",
|
||||
extraConfig: { username: "elastic" },
|
||||
}),
|
||||
provider({
|
||||
telemetryProviderId: "bs1",
|
||||
name: "better stack",
|
||||
providerType: "betterstack",
|
||||
signals: ["logs", "metrics"],
|
||||
endpoint: "in.logs.betterstack.com",
|
||||
apiKey: "token",
|
||||
}),
|
||||
];
|
||||
|
||||
const cases: Array<[string, Parameters<typeof buildVectorConfigYaml>[0]]> = [
|
||||
[
|
||||
"local logs only",
|
||||
{
|
||||
logProviders: logsOnly,
|
||||
metricsProviders: [],
|
||||
organizationId: "org_a",
|
||||
serverName: "Dokploy Server (local)",
|
||||
},
|
||||
],
|
||||
[
|
||||
"local metrics only",
|
||||
{
|
||||
logProviders: [],
|
||||
metricsProviders: metricsOnly,
|
||||
organizationId: "org_a",
|
||||
serverName: "Dokploy Server (local)",
|
||||
},
|
||||
],
|
||||
[
|
||||
"local every provider on both signals",
|
||||
{
|
||||
logProviders: [...logsOnly, ...both],
|
||||
metricsProviders: [...metricsOnly, ...both],
|
||||
organizationId: "org_a",
|
||||
serverName: 'Dokploy "Server" $HOSTNAME (local)',
|
||||
},
|
||||
],
|
||||
[
|
||||
"remote metrics only",
|
||||
{ logProviders: [], metricsProviders: metricsOnly, serverName: "edge-1" },
|
||||
],
|
||||
[
|
||||
"remote every provider on both signals",
|
||||
{
|
||||
logProviders: [...logsOnly, ...both],
|
||||
metricsProviders: [...metricsOnly, ...both],
|
||||
serverName: "edge-1",
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
describe.skipIf(!dockerAvailable)(
|
||||
"vector validate on generated configs",
|
||||
() => {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "vector-validate-"));
|
||||
|
||||
it.each(cases)(
|
||||
"%s",
|
||||
(name, input) => {
|
||||
const file = path.join(dir, `${name.replace(/\W+/g, "-")}.yaml`);
|
||||
writeFileSync(file, buildVectorConfigYaml(input));
|
||||
const output = execFileSync(
|
||||
"docker",
|
||||
[
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
"/var/run/docker.sock:/var/run/docker.sock:ro",
|
||||
"-v",
|
||||
`${file}:/etc/vector/vector.yaml:ro`,
|
||||
VECTOR_IMAGE,
|
||||
"validate",
|
||||
"--skip-healthchecks",
|
||||
"/etc/vector/vector.yaml",
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: 120_000,
|
||||
},
|
||||
);
|
||||
expect(output).toContain("Validated");
|
||||
},
|
||||
150_000,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,86 +0,0 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { PgDialect } from "drizzle-orm/pg-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findFirst: vi.fn(),
|
||||
updates: [] as Array<{ values: Record<string, unknown>; where: unknown }>,
|
||||
returning: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: { webServerSettings: { findFirst: mocks.findFirst } },
|
||||
update: () => ({
|
||||
set: (values: Record<string, unknown>) => ({
|
||||
where: (where: unknown) => {
|
||||
mocks.updates.push({ values, where });
|
||||
return { returning: () => Promise.resolve(mocks.returning()) };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const { releaseWebServerAgent, setWebServerProviderIds } = await import(
|
||||
"@dokploy/server/services/web-server-settings"
|
||||
);
|
||||
|
||||
const render = (value: unknown) => new PgDialect().sqlToQuery(value as SQL).sql;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.updates = [];
|
||||
mocks.findFirst.mockResolvedValue({
|
||||
id: "ws-1",
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
});
|
||||
mocks.returning.mockReturnValue([{ id: "ws-1" }]);
|
||||
});
|
||||
|
||||
describe("setWebServerProviderIds", () => {
|
||||
it("claims the local agent for the organization when assigning providers", async () => {
|
||||
const row = await setWebServerProviderIds("org-a", ["lp-1", "mp-1"]);
|
||||
|
||||
expect(row).toEqual({ id: "ws-1" });
|
||||
const update = mocks.updates[0];
|
||||
expect(update?.values).toMatchObject({
|
||||
telemetryProviderIds: ["lp-1", "mp-1"],
|
||||
vectorAgentOrganizationId: "org-a",
|
||||
});
|
||||
expect(render(update?.where)).toBe(
|
||||
'("webServerSettings"."id" = $1 and ("webServerSettings"."vectorAgentOrganizationId" is null or "webServerSettings"."vectorAgentOrganizationId" = $2))',
|
||||
);
|
||||
});
|
||||
|
||||
it("releases the owner when the selection is cleared", async () => {
|
||||
await setWebServerProviderIds("org-a", []);
|
||||
|
||||
expect(mocks.updates[0]?.values).toMatchObject({
|
||||
telemetryProviderIds: [],
|
||||
vectorAgentOrganizationId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when another organization owns the local agent", async () => {
|
||||
mocks.returning.mockReturnValue([]);
|
||||
|
||||
await expect(
|
||||
setWebServerProviderIds("org-b", ["lp-9"]),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("releaseWebServerAgent", () => {
|
||||
it("clears the selection and the owner only for the owning organization", async () => {
|
||||
await releaseWebServerAgent("org-a");
|
||||
|
||||
const update = mocks.updates[0];
|
||||
expect(update?.values).toMatchObject({
|
||||
telemetryProviderIds: [],
|
||||
vectorAgentOrganizationId: null,
|
||||
});
|
||||
expect(render(update?.where)).toBe(
|
||||
'"webServerSettings"."vectorAgentOrganizationId" = $1',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -73,20 +73,6 @@ describe("owner and admin bypass enterprise resources", () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("owner bypasses telemetryProvider.read", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
checkPermission(ctx, { telemetryProvider: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("admin bypasses telemetryProvider.create", async () => {
|
||||
memberToReturn = mockMemberData("admin");
|
||||
await expect(
|
||||
checkPermission(ctx, { telemetryProvider: ["create"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("owner bypasses multiple enterprise permissions at once", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
@@ -153,20 +139,6 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
|
||||
checkPermission(ctx, { registry: ["create"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied telemetryProvider.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { telemetryProvider: ["read"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member is denied telemetryProvider.create", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { telemetryProvider: ["create"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("static roles validate free-tier resources", () => {
|
||||
|
||||
@@ -18,7 +18,6 @@ const FREE_TIER_RESOURCES = [
|
||||
"gitProviders",
|
||||
"traefikFiles",
|
||||
"api",
|
||||
"telemetryProvider",
|
||||
];
|
||||
|
||||
const ENTERPRISE_RESOURCES = [
|
||||
|
||||
@@ -111,7 +111,6 @@ describe("enterprise resources for static roles", () => {
|
||||
expect(perms.destination.read).toBe(false);
|
||||
expect(perms.notification.read).toBe(false);
|
||||
expect(perms.auditLog.read).toBe(false);
|
||||
expect(perms.telemetryProvider.read).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -154,16 +154,7 @@ const hasRealMonitoring = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const hasDocker = () => {
|
||||
try {
|
||||
execSync("docker info", { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
describe.skipIf(!hasDocker() || hasRealMonitoring() || !process.env.CI)(
|
||||
describe.skipIf(hasRealMonitoring())(
|
||||
"setupMonitoring - legacy container cleanup (real docker)",
|
||||
() => {
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -25,8 +25,6 @@ const baseSettings: WebServerSettings = {
|
||||
letsEncryptEmail: null,
|
||||
sshPrivateKey: null,
|
||||
enableDockerCleanup: false,
|
||||
telemetryProviderIds: [],
|
||||
vectorAgentOrganizationId: null,
|
||||
buildsConcurrency: 1,
|
||||
logCleanupCron: null,
|
||||
metricsConfig: {
|
||||
@@ -62,12 +60,8 @@ const baseSettings: WebServerSettings = {
|
||||
docsUrl: null,
|
||||
errorPageTitle: null,
|
||||
errorPageDescription: null,
|
||||
ogImageUrl: null,
|
||||
metaTitle: null,
|
||||
footerText: null,
|
||||
logoLightUrl: null,
|
||||
loginLogoLightUrl: null,
|
||||
faviconLightUrl: null,
|
||||
dualModeBranding: false,
|
||||
},
|
||||
cleanupCacheApplications: false,
|
||||
cleanupCacheOnCompose: false,
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findServerById: vi.fn(),
|
||||
readDirectory: vi.fn(),
|
||||
readConfigInPath: vi.fn(),
|
||||
writeTraefikConfigInPath: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/permission", () => ({
|
||||
checkPermission: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server", () => ({
|
||||
IS_CLOUD: false,
|
||||
paths: () => ({ MAIN_TRAEFIK_PATH: "/etc/dokploy/traefik" }),
|
||||
...mocks,
|
||||
}));
|
||||
|
||||
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", () => ({
|
||||
cleanAllDeploymentQueue: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/utils/backup", () => ({
|
||||
removeJob: vi.fn(),
|
||||
schedule: vi.fn(),
|
||||
}));
|
||||
|
||||
const { settingsRouter } = await import("@/server/api/routers/settings");
|
||||
|
||||
const caller = settingsRouter.createCaller({
|
||||
session: { activeOrganizationId: "org-a" },
|
||||
user: { id: "user-1", email: "user@example.com", role: "member" },
|
||||
} as Parameters<typeof settingsRouter.createCaller>[0]);
|
||||
|
||||
// apiReadTraefikConfig validates against the real local paths(), which outside
|
||||
// production live under the working directory.
|
||||
const filePath = path.join(process.cwd(), ".docker/traefik/dynamic/app.yml");
|
||||
|
||||
describe("traefik file endpoints scope remote servers to the active organization", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.findServerById.mockImplementation(async (serverId: string) => ({
|
||||
serverId,
|
||||
organizationId: serverId === "server-a" ? "org-a" : "org-b",
|
||||
}));
|
||||
mocks.readDirectory.mockResolvedValue([]);
|
||||
mocks.readConfigInPath.mockResolvedValue("http: {}");
|
||||
});
|
||||
|
||||
it("rejects listing a server from another organization", async () => {
|
||||
await expect(
|
||||
caller.readDirectories({ serverId: "server-b" }),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.readDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects writing to a server from another organization", async () => {
|
||||
await expect(
|
||||
caller.updateTraefikFile({
|
||||
path: filePath,
|
||||
traefikConfig: "http: {}",
|
||||
serverId: "server-b",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.writeTraefikConfigInPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects reading from a server from another organization", async () => {
|
||||
await expect(
|
||||
caller.readTraefikFile({ path: filePath, serverId: "server-b" }),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
expect(mocks.readConfigInPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows servers of the active organization", async () => {
|
||||
await caller.readDirectories({ serverId: "server-a" });
|
||||
await caller.readTraefikFile({ path: filePath, serverId: "server-a" });
|
||||
await caller.updateTraefikFile({
|
||||
path: filePath,
|
||||
traefikConfig: "http: {}",
|
||||
serverId: "server-a",
|
||||
});
|
||||
|
||||
expect(mocks.readDirectory).toHaveBeenCalledWith(
|
||||
"/etc/dokploy/traefik",
|
||||
"server-a",
|
||||
);
|
||||
expect(mocks.readConfigInPath).toHaveBeenCalledWith(filePath, "server-a");
|
||||
expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith(
|
||||
filePath,
|
||||
"http: {}",
|
||||
"server-a",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not look up a server when none is given", async () => {
|
||||
await caller.readDirectories({});
|
||||
await caller.updateTraefikFile({
|
||||
path: filePath,
|
||||
traefikConfig: "http: {}",
|
||||
});
|
||||
|
||||
expect(mocks.findServerById).not.toHaveBeenCalled();
|
||||
expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith(
|
||||
filePath,
|
||||
"http: {}",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execAsyncRemote: vi.fn(),
|
||||
writeFileRemote: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
|
||||
@@ -17,7 +16,6 @@ vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
execAsyncRemote: mocks.execAsyncRemote,
|
||||
writeFileRemote: mocks.writeFileRemote,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -93,7 +91,7 @@ describe("writeAppTraefikConfig", () => {
|
||||
});
|
||||
|
||||
it("writes the remote file when routers/services are present", async () => {
|
||||
mocks.writeFileRemote.mockResolvedValue(undefined);
|
||||
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
|
||||
await writeAppTraefikConfig(
|
||||
{
|
||||
@@ -111,11 +109,8 @@ describe("writeAppTraefikConfig", () => {
|
||||
"server-id",
|
||||
);
|
||||
|
||||
expect(mocks.writeFileRemote).toHaveBeenCalledOnce();
|
||||
const [serverId, remotePath, content] =
|
||||
mocks.writeFileRemote.mock.calls[0] ?? [];
|
||||
expect(serverId).toBe("server-id");
|
||||
expect(remotePath).toContain("with-domain-app.yml");
|
||||
expect(content).toContain("with-domain-app-router-1");
|
||||
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
|
||||
const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? [];
|
||||
expect(command).toMatch(/^echo /);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import {
|
||||
checkDeletionCode,
|
||||
generateDeletionCode,
|
||||
serializeDeletionCode,
|
||||
} from "@dokploy/server/services/account-deletion";
|
||||
import { deleteHubSpotContactByEmail } from "@dokploy/server/utils/tracking/hubspot";
|
||||
import Stripe from "stripe";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { cancelStripeSubscriptions } from "@/server/utils/billing";
|
||||
|
||||
const makeStripe = (
|
||||
subscriptions: Array<{ id: string; status: string }>,
|
||||
listError?: Error,
|
||||
) => {
|
||||
const stripe = {
|
||||
subscriptions: {
|
||||
list: vi.fn(() =>
|
||||
listError
|
||||
? Promise.reject(listError)
|
||||
: Promise.resolve({ data: subscriptions }),
|
||||
),
|
||||
cancel: vi.fn(() => Promise.resolve({})),
|
||||
},
|
||||
};
|
||||
return stripe as unknown as Stripe & typeof stripe;
|
||||
};
|
||||
|
||||
describe("cancelStripeSubscriptions", () => {
|
||||
test("cancels every non-terminal subscription without invoicing", async () => {
|
||||
const stripe = makeStripe([
|
||||
{ id: "sub_active", status: "active" },
|
||||
{ id: "sub_trial", status: "trialing" },
|
||||
{ id: "sub_past_due", status: "past_due" },
|
||||
{ id: "sub_canceled", status: "canceled" },
|
||||
{ id: "sub_expired", status: "incomplete_expired" },
|
||||
]);
|
||||
|
||||
const result = await cancelStripeSubscriptions("cus_1", stripe);
|
||||
|
||||
expect(stripe.subscriptions.list).toHaveBeenCalledWith({
|
||||
customer: "cus_1",
|
||||
status: "all",
|
||||
limit: 100,
|
||||
});
|
||||
expect(stripe.subscriptions.cancel).toHaveBeenCalledTimes(3);
|
||||
expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_active", {
|
||||
invoice_now: false,
|
||||
prorate: false,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
cancelledSubscriptions: ["sub_active", "sub_trial", "sub_past_due"],
|
||||
});
|
||||
});
|
||||
|
||||
test("treats a missing customer as having nothing to cancel", async () => {
|
||||
const missing = new Stripe.errors.StripeInvalidRequestError({
|
||||
type: "invalid_request_error",
|
||||
code: "resource_missing",
|
||||
message: "No such customer",
|
||||
});
|
||||
const stripe = makeStripe([], missing);
|
||||
|
||||
const result = await cancelStripeSubscriptions("cus_missing", stripe);
|
||||
|
||||
expect(stripe.subscriptions.cancel).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ cancelledSubscriptions: [] });
|
||||
});
|
||||
|
||||
test("propagates unexpected Stripe errors", async () => {
|
||||
const failure = new Stripe.errors.StripeAPIError({
|
||||
type: "api_error",
|
||||
message: "boom",
|
||||
});
|
||||
const stripe = makeStripe([], failure);
|
||||
|
||||
await expect(cancelStripeSubscriptions("cus_1", stripe)).rejects.toBe(
|
||||
failure,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteHubSpotContactByEmail", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("is skipped when no access token is configured", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
expect(await deleteHubSpotContactByEmail("a@b.com", undefined)).toBe(
|
||||
"skipped",
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls the GDPR delete endpoint with the email as id", async () => {
|
||||
const fetchMock = vi.fn(() =>
|
||||
Promise.resolve(new Response(null, { status: 204 })),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
|
||||
"deleted",
|
||||
);
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(url).toBe(
|
||||
"https://api.hubapi.com/crm/v3/objects/contacts/gdpr-delete",
|
||||
);
|
||||
expect(init.method).toBe("POST");
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||
"Bearer token",
|
||||
);
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
idProperty: "email",
|
||||
objectId: "a@b.com",
|
||||
});
|
||||
});
|
||||
|
||||
test("maps 404 to not_found and other errors to failed", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.resolve(new Response(null, { status: 404 }))),
|
||||
);
|
||||
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
|
||||
"not_found",
|
||||
);
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.resolve(new Response("nope", { status: 500 }))),
|
||||
);
|
||||
expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe(
|
||||
"failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("account deletion codes", () => {
|
||||
test("generates six-digit numeric codes", () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(generateDeletionCode()).toMatch(/^\d{6}$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts the right code, ignoring surrounding whitespace", () => {
|
||||
const value = serializeDeletionCode("482913");
|
||||
|
||||
expect(checkDeletionCode(value, "482913")).toEqual({
|
||||
valid: true,
|
||||
attempts: 0,
|
||||
});
|
||||
expect(checkDeletionCode(value, " 482913 ").valid).toBe(true);
|
||||
});
|
||||
|
||||
test("counts failed attempts without revealing the hash", () => {
|
||||
const value = serializeDeletionCode("482913");
|
||||
|
||||
const first = checkDeletionCode(value, "000000");
|
||||
expect(first.valid).toBe(false);
|
||||
if (first.valid) throw new Error("unreachable");
|
||||
expect(first.attempts).toBe(1);
|
||||
expect(JSON.parse(first.nextValue)).toEqual({
|
||||
...JSON.parse(value),
|
||||
attempts: 1,
|
||||
});
|
||||
|
||||
const second = checkDeletionCode(first.nextValue, "999999");
|
||||
expect(second.valid).toBe(false);
|
||||
if (second.valid) throw new Error("unreachable");
|
||||
expect(second.attempts).toBe(2);
|
||||
|
||||
expect(checkDeletionCode(second.nextValue, "482913")).toEqual({
|
||||
valid: true,
|
||||
attempts: 2,
|
||||
});
|
||||
expect(value).not.toContain("482913");
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServerById: vi.fn(),
|
||||
}));
|
||||
|
||||
import { pipeBetweenServers } from "@dokploy/server/utils/process/remoteStream";
|
||||
|
||||
describe("pipeBetweenServers", () => {
|
||||
it("delivers a short source stream that ends before the target is ready", async () => {
|
||||
const bytes = await pipeBetweenServers({
|
||||
source: { serverId: null, command: "printf 'hello world'" },
|
||||
target: { serverId: null, command: "sleep 0.3; cat > /dev/null" },
|
||||
});
|
||||
expect(bytes).toBe(11);
|
||||
});
|
||||
|
||||
it("pipes data larger than the pipe buffer unchanged", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-"));
|
||||
const source = path.join(dir, "src.bin");
|
||||
const target = path.join(dir, "dst.bin");
|
||||
const size = 1024 * 1024;
|
||||
const progress: number[] = [];
|
||||
|
||||
const bytes = await pipeBetweenServers({
|
||||
source: {
|
||||
serverId: null,
|
||||
command: `head -c ${size} /dev/urandom | tee '${source}'`,
|
||||
},
|
||||
target: { serverId: null, command: `cat > '${target}'` },
|
||||
onProgress: (transferred) => progress.push(transferred),
|
||||
});
|
||||
|
||||
expect(bytes).toBe(size);
|
||||
expect(progress.at(-1)).toBe(size);
|
||||
expect(await fs.readFile(target)).toEqual(await fs.readFile(source));
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}, 30_000);
|
||||
|
||||
it("reports a target failure with its stderr", async () => {
|
||||
await expect(
|
||||
pipeBetweenServers({
|
||||
source: { serverId: null, command: "printf x" },
|
||||
target: { serverId: null, command: "echo boom >&2; exit 3" },
|
||||
}),
|
||||
).rejects.toThrow("target exited with code 3: boom");
|
||||
});
|
||||
|
||||
it("reports a source failure", async () => {
|
||||
await expect(
|
||||
pipeBetweenServers({
|
||||
source: { serverId: null, command: "exit 2" },
|
||||
target: { serverId: null, command: "cat > /dev/null" },
|
||||
}),
|
||||
).rejects.toThrow("source exited with code 2");
|
||||
});
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import WebSocket from "ws";
|
||||
|
||||
class FakeChild extends EventEmitter {
|
||||
stdout = new EventEmitter();
|
||||
stderr = new EventEmitter();
|
||||
killed = false;
|
||||
exitCode: number | null = null;
|
||||
signalCode: NodeJS.Signals | null = null;
|
||||
signals: string[] = [];
|
||||
// Mirrors ChildProcess: `killed` flips as soon as a signal is delivered,
|
||||
// even if the process ignores it and keeps running.
|
||||
ignoreSigterm = false;
|
||||
|
||||
kill(signal: NodeJS.Signals = "SIGTERM") {
|
||||
this.signals.push(signal);
|
||||
this.killed = true;
|
||||
if (signal === "SIGTERM" && this.ignoreSigterm) return true;
|
||||
this.signalCode = signal;
|
||||
this.emit("close", null, signal);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const children = vi.hoisted(() => [] as FakeChild[]);
|
||||
const nextChild = vi.hoisted(() => ({ ignoreSigterm: false }));
|
||||
vi.mock("node:child_process", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("node:child_process")>()),
|
||||
spawn: vi.fn(() => {
|
||||
const child = new FakeChild();
|
||||
child.ignoreSigterm = nextChild.ignoreSigterm;
|
||||
children.push(child);
|
||||
return child;
|
||||
}),
|
||||
}));
|
||||
|
||||
const sshClients = vi.hoisted(
|
||||
() => [] as { connect: ReturnType<typeof vi.fn> }[],
|
||||
);
|
||||
vi.mock("ssh2", () => ({
|
||||
Client: vi.fn(function (this: Record<string, unknown>) {
|
||||
this.on = vi.fn(() => this);
|
||||
this.connect = vi.fn(() => this);
|
||||
this.end = vi.fn();
|
||||
sshClients.push(this as never);
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockValidateRequest = vi.hoisted(() => vi.fn());
|
||||
const mockFindServerById = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@dokploy/server", () => ({
|
||||
IS_CLOUD: false,
|
||||
validateRequest: mockValidateRequest,
|
||||
findServerById: mockFindServerById,
|
||||
}));
|
||||
vi.mock("@dokploy/server/wss/utils", () => ({
|
||||
readValidDirectory: () => true,
|
||||
}));
|
||||
|
||||
import { setupDeploymentLogsWebSocketServer } from "@/server/wss/listen-deployment";
|
||||
|
||||
const AUTH = {
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
};
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
const flush = () => new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
let server: http.Server;
|
||||
let url: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
children.length = 0;
|
||||
sshClients.length = 0;
|
||||
nextChild.ignoreSigterm = false;
|
||||
vi.clearAllMocks();
|
||||
server = http.createServer();
|
||||
setupDeploymentLogsWebSocketServer(server);
|
||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
url = `ws://127.0.0.1:${port}/listen-deployment?logPath=/etc/dokploy/logs/app/deploy.log`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await new Promise((r) => server.close(r));
|
||||
});
|
||||
|
||||
const connect = async (query = "") => {
|
||||
const ws = new WebSocket(`${url}${query}`);
|
||||
await new Promise((r) => ws.once("open", r));
|
||||
return ws;
|
||||
};
|
||||
|
||||
const closeClient = async (ws: WebSocket) => {
|
||||
ws.close();
|
||||
await new Promise((r) => ws.once("close", r));
|
||||
await flush();
|
||||
};
|
||||
|
||||
describe("deployment logs websocket (#5518)", () => {
|
||||
it("kills tail when the client disconnects", async () => {
|
||||
mockValidateRequest.mockResolvedValue(AUTH);
|
||||
const ws = await connect();
|
||||
await flush();
|
||||
expect(children).toHaveLength(1);
|
||||
|
||||
await closeClient(ws);
|
||||
expect(children[0]!.signals).toContain("SIGTERM");
|
||||
});
|
||||
|
||||
it("does not spawn tail when the client disconnects during auth", async () => {
|
||||
const auth = deferred<typeof AUTH>();
|
||||
mockValidateRequest.mockReturnValue(auth.promise);
|
||||
const ws = await connect();
|
||||
|
||||
await closeClient(ws);
|
||||
auth.resolve(AUTH);
|
||||
await flush();
|
||||
|
||||
const leaked = children.filter(
|
||||
(c) => c.exitCode === null && c.signalCode === null,
|
||||
);
|
||||
expect(leaked).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not open an SSH session when the client disconnects while loading the server", async () => {
|
||||
mockValidateRequest.mockResolvedValue(AUTH);
|
||||
const serverLookup = deferred<unknown>();
|
||||
mockFindServerById.mockReturnValue(serverLookup.promise);
|
||||
const ws = await connect("&serverId=srv-1");
|
||||
|
||||
await closeClient(ws);
|
||||
serverLookup.resolve({
|
||||
organizationId: "org-1",
|
||||
sshKeyId: "key-1",
|
||||
sshKey: { privateKey: "key" },
|
||||
ipAddress: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
});
|
||||
await flush();
|
||||
|
||||
const connected = sshClients.filter((c) => c.connect.mock.calls.length > 0);
|
||||
expect(connected).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("force-kills tail with SIGKILL when SIGTERM does not stop it", async () => {
|
||||
nextChild.ignoreSigterm = true;
|
||||
mockValidateRequest.mockResolvedValue(AUTH);
|
||||
const ws = await connect();
|
||||
await flush();
|
||||
|
||||
await closeClient(ws);
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
expect(children[0]!.signals).toEqual(["SIGTERM", "SIGKILL"]);
|
||||
});
|
||||
});
|
||||
@@ -8,3 +8,4 @@ export { RestartPolicyForm } from "./restart-policy-form";
|
||||
export { RollbackConfigForm } from "./rollback-config-form";
|
||||
export { StopGracePeriodForm } from "./stop-grace-period-form";
|
||||
export { UpdateConfigForm } from "./update-config-form";
|
||||
export { filterEmptyValues, hasValues } from "./utils";
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Filters out undefined, null, and empty string values from form data
|
||||
* Only returns fields that have actual values
|
||||
*/
|
||||
export const filterEmptyValues = (
|
||||
formData: Record<string, any>,
|
||||
): Record<string, any> => {
|
||||
return Object.entries(formData).reduce(
|
||||
(acc, [key, value]) => {
|
||||
// Keep arrays even if empty (they might be intentionally cleared)
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) {
|
||||
acc[key] = value;
|
||||
}
|
||||
}
|
||||
// For other values, filter out undefined, null, and empty strings
|
||||
else if (value !== undefined && value !== null && value !== "") {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if filtered data has any values to save
|
||||
*/
|
||||
export const hasValues = (data: Record<string, any>): boolean => {
|
||||
return Object.keys(data).length > 0;
|
||||
};
|
||||
@@ -31,45 +31,6 @@ import { api } from "@/utils/api";
|
||||
|
||||
// Railpack versions from https://github.com/railwayapp/railpack/releases
|
||||
export const RAILPACK_VERSIONS = [
|
||||
"0.39.0",
|
||||
"0.38.0",
|
||||
"0.37.1",
|
||||
"0.37.0",
|
||||
"0.36.4",
|
||||
"0.36.3",
|
||||
"0.36.2",
|
||||
"0.36.1",
|
||||
"0.36.0",
|
||||
"0.35.0",
|
||||
"0.34.0",
|
||||
"0.33.0",
|
||||
"0.32.0",
|
||||
"0.31.2",
|
||||
"0.31.1",
|
||||
"0.31.0",
|
||||
"0.30.1",
|
||||
"0.30.0",
|
||||
"0.29.0",
|
||||
"0.28.0",
|
||||
"0.27.2",
|
||||
"0.27.1",
|
||||
"0.27.0",
|
||||
"0.26.1",
|
||||
"0.26.0",
|
||||
"0.25.0",
|
||||
"0.24.0",
|
||||
"0.23.0",
|
||||
"0.22.2",
|
||||
"0.22.1",
|
||||
"0.22.0",
|
||||
"0.21.0",
|
||||
"0.20.0",
|
||||
"0.19.0",
|
||||
"0.18.0",
|
||||
"0.17.2",
|
||||
"0.17.1",
|
||||
"0.17.0",
|
||||
"0.16.0",
|
||||
"0.15.4",
|
||||
"0.15.3",
|
||||
"0.15.2",
|
||||
@@ -84,22 +45,12 @@ export const RAILPACK_VERSIONS = [
|
||||
"0.9.1",
|
||||
"0.9.0",
|
||||
"0.8.0",
|
||||
"0.7.2",
|
||||
"0.7.1",
|
||||
"0.7.0",
|
||||
"0.6.1",
|
||||
"0.6.0",
|
||||
"0.5.1",
|
||||
"0.5.0",
|
||||
"0.4.0",
|
||||
"0.3.0",
|
||||
"0.2.3",
|
||||
"0.2.2",
|
||||
"0.2.1",
|
||||
"0.2.0",
|
||||
"0.1.2",
|
||||
"0.1.1",
|
||||
"0.1.0",
|
||||
] as const;
|
||||
|
||||
export enum BuildType {
|
||||
|
||||
@@ -156,7 +156,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
domainId,
|
||||
},
|
||||
{
|
||||
enabled: isOpen && !!domainId,
|
||||
enabled: !!domainId,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -167,7 +167,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
applicationId: id,
|
||||
},
|
||||
{
|
||||
enabled: isOpen && !!id,
|
||||
enabled: !!id,
|
||||
},
|
||||
)
|
||||
: api.compose.one.useQuery(
|
||||
@@ -175,7 +175,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
composeId: id,
|
||||
},
|
||||
{
|
||||
enabled: isOpen && !!id,
|
||||
enabled: !!id,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -187,14 +187,9 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
api.domain.generateDomain.useMutation();
|
||||
|
||||
const { data: canGenerateTraefikMeDomains } =
|
||||
api.domain.canGenerateTraefikMeDomains.useQuery(
|
||||
{
|
||||
serverId: application?.serverId || "",
|
||||
},
|
||||
{
|
||||
enabled: isOpen,
|
||||
},
|
||||
);
|
||||
api.domain.canGenerateTraefikMeDomains.useQuery({
|
||||
serverId: application?.serverId || "",
|
||||
});
|
||||
|
||||
const {
|
||||
data: services,
|
||||
@@ -209,7 +204,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
{
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: isOpen && type === "compose" && !!id,
|
||||
enabled: type === "compose" && !!id,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
|
||||
import { VaultImportDialog } from "@/components/shared/vault-import-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -198,18 +197,6 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
name="environment"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex justify-end">
|
||||
<VaultImportDialog
|
||||
projectId={data?.environment?.projectId}
|
||||
environmentId={data?.environment?.environmentId}
|
||||
currentEnv={field.value ?? ""}
|
||||
onImport={(next) =>
|
||||
form.setValue("environment", next, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormControl className="">
|
||||
<CodeEditor
|
||||
style={
|
||||
|
||||
@@ -151,8 +151,6 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
}
|
||||
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
|
||||
completionSource={completionSource}
|
||||
projectId={data?.environment?.projectId}
|
||||
environmentId={data?.environment?.environmentId}
|
||||
/>
|
||||
{data?.buildType === "dockerfile" && (
|
||||
<Secrets
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -13,7 +14,6 @@ import { Dropzone } from "@/components/ui/dropzone";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
|
||||
import { api } from "@/utils/api";
|
||||
import { sanitizeSvg } from "@/utils/sanitize-svg";
|
||||
|
||||
interface ShowIconSettingsProps {
|
||||
serviceId: string;
|
||||
@@ -89,6 +89,15 @@ export const ShowIconSettings = ({
|
||||
}
|
||||
};
|
||||
|
||||
const sanitizeSvg = (svgContent: string): string | null => {
|
||||
const clean = DOMPurify.sanitize(svgContent, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
ADD_TAGS: ["use"],
|
||||
});
|
||||
if (!clean) return null;
|
||||
return `data:image/svg+xml;base64,${btoa(clean)}`;
|
||||
};
|
||||
|
||||
const handleFileUpload = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
const file = files[0];
|
||||
|
||||
@@ -84,9 +84,9 @@ export const ShowPatches = ({ id, type }: Props) => {
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl">Patches</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Patches</CardTitle>
|
||||
<CardDescription>
|
||||
Apply code patches to your repository during build. Patches are
|
||||
applied after cloning the repository and before building.
|
||||
|
||||
@@ -77,19 +77,23 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-background h-full min-h-[50vh]">
|
||||
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl">Scheduled Tasks</CardTitle>
|
||||
<CardDescription>
|
||||
Schedule tasks to run automatically at specified intervals.
|
||||
</CardDescription>
|
||||
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
||||
<CardHeader className="px-0">
|
||||
<div className="flex justify-between items-center gap-y-2 flex-wrap">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||
Scheduled Tasks
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Schedule tasks to run automatically at specified intervals.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{schedules && schedules.length > 0 && (
|
||||
<HandleSchedules id={id} scheduleType={scheduleType} />
|
||||
)}
|
||||
</div>
|
||||
{schedules && schedules.length > 0 && (
|
||||
<HandleSchedules id={id} scheduleType={scheduleType} />
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="px-0">
|
||||
{isLoadingSchedules ? (
|
||||
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||
|
||||
+27
-18
@@ -77,26 +77,35 @@ export const ShowVolumeBackups = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-background h-full min-h-[50vh]">
|
||||
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl">Volume Backups</CardTitle>
|
||||
<CardDescription>
|
||||
Schedule volume backups to run automatically at specified intervals
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{volumeBackups && volumeBackups.length > 0 && (
|
||||
<>
|
||||
<HandleVolumeBackups id={id} volumeBackupType={type} />
|
||||
<div className="flex items-center gap-2">
|
||||
<RestoreVolumeBackups id={id} type={type} serverId={serverId} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
||||
<CardHeader className="px-0">
|
||||
<div className="flex justify-between items-center flex-wrap gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||
Volume Backups
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Schedule volume backups to run automatically at specified
|
||||
intervals
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{volumeBackups && volumeBackups.length > 0 && (
|
||||
<>
|
||||
<HandleVolumeBackups id={id} volumeBackupType={type} />
|
||||
<div className="flex items-center gap-2">
|
||||
<RestoreVolumeBackups
|
||||
id={id}
|
||||
type={type}
|
||||
serverId={serverId}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="px-0">
|
||||
{isLoadingVolumeBackups ? (
|
||||
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Rocket } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const TrialBanner = () => {
|
||||
const router = useRouter();
|
||||
const { data: billingStatus } = api.stripe.getBillingStatus.useQuery();
|
||||
const { mutateAsync: createCustomerPortalSession, isPending } =
|
||||
api.stripe.createCustomerPortalSession.useMutation();
|
||||
|
||||
const handleAddPaymentMethod = async () => {
|
||||
const session = await createCustomerPortalSession().catch(() => null);
|
||||
if (session?.url) {
|
||||
window.open(session.url);
|
||||
return;
|
||||
}
|
||||
router.push("/dashboard/settings/billing");
|
||||
};
|
||||
|
||||
if (!billingStatus?.isOnTrial || billingStatus.hasPaymentMethod) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const daysRemaining = billingStatus.trialDaysRemaining ?? 0;
|
||||
const daysLabel = `${daysRemaining} day${daysRemaining === 1 ? "" : "s"}`;
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-20 mx-4 mt-4 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 rounded-lg border border-primary/20 bg-primary/10 px-4 py-2 text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<Rocket className="h-4 w-4 text-primary shrink-0" />
|
||||
{daysRemaining > 0
|
||||
? `You have ${daysLabel} left in your free trial. Add a payment method to keep your servers.`
|
||||
: "Your free trial ends today. Add a payment method to keep your servers."}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-7"
|
||||
isLoading={isPending}
|
||||
onClick={handleAddPaymentMethod}
|
||||
>
|
||||
Add payment method
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
@@ -31,7 +30,6 @@ interface Props {
|
||||
|
||||
const AddRedirectSchema = z.object({
|
||||
command: z.string(),
|
||||
pullImages: z.boolean(),
|
||||
});
|
||||
|
||||
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
||||
@@ -59,25 +57,22 @@ export const AddCommandCompose = ({ composeId }: Props) => {
|
||||
const form = useForm<AddCommand>({
|
||||
defaultValues: {
|
||||
command: "",
|
||||
pullImages: false,
|
||||
},
|
||||
resolver: zodResolver(AddRedirectSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
if (data?.command) {
|
||||
form.reset({
|
||||
command: data.command || "",
|
||||
pullImages: data.pullImages ?? false,
|
||||
command: data?.command || "",
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
|
||||
|
||||
const onSubmit = async (formData: AddCommand) => {
|
||||
const onSubmit = async (data: AddCommand) => {
|
||||
await mutateAsync({
|
||||
composeId,
|
||||
command: formData.command,
|
||||
pullImages: formData.pullImages,
|
||||
command: data?.command,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Command Updated");
|
||||
@@ -114,30 +109,6 @@ export const AddCommandCompose = ({ composeId }: Props) => {
|
||||
<strong>docker</strong>.
|
||||
</AlertBlock>
|
||||
<div className="flex flex-col gap-4">
|
||||
{data?.composeType === "docker-compose" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="pullImages"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-xs">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Pull latest images on deploy</FormLabel>
|
||||
<FormDescription>
|
||||
Adds <strong>--pull always</strong> to the default
|
||||
command so every deploy fetches the newest image for
|
||||
each tag. Has no effect when a custom command is set.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="command"
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { AlertTriangle, Copy, HardDriveDownload } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const freshVolumesSchema = z.object({
|
||||
confirmName: z.string().min(1, {
|
||||
message: "Compose name is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type FreshVolumesForm = z.infer<typeof freshVolumesSchema>;
|
||||
|
||||
interface Props {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
export const FreshVolumes = ({ composeId }: Props) => {
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDeploy = permissions?.deployment.create ?? false;
|
||||
const { data, refetch } = api.compose.one.useQuery(
|
||||
{ composeId },
|
||||
{ enabled: !!composeId },
|
||||
);
|
||||
const { mutateAsync: deploy, isPending } = api.compose.deploy.useMutation();
|
||||
|
||||
const form = useForm<FreshVolumesForm>({
|
||||
defaultValues: { confirmName: "" },
|
||||
resolver: zodResolver(freshVolumesSchema),
|
||||
});
|
||||
|
||||
const expectedName = `${data?.name}/${data?.appName}`;
|
||||
const isRunning = data?.composeStatus === "running";
|
||||
|
||||
const onSubmit = async (formData: FreshVolumesForm) => {
|
||||
if (formData.confirmName !== expectedName) {
|
||||
form.setError("confirmName", {
|
||||
message: `Compose name must match "${expectedName}"`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await deploy({ composeId, freshVolumes: true })
|
||||
.then(() => {
|
||||
toast.success("Compose deployed with fresh volumes");
|
||||
setIsOpen(false);
|
||||
form.reset();
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deploying compose");
|
||||
});
|
||||
};
|
||||
|
||||
if (!canDeploy || data?.composeType !== "docker-compose") return null;
|
||||
|
||||
return (
|
||||
<Card className="bg-background border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
Danger Zone
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-base font-semibold">
|
||||
Deploy with Fresh Volumes
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Permanently wipes every volume of this compose and redeploys it
|
||||
from a clean state. All persistent data (databases, uploads,
|
||||
caches) will be lost.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) form.reset();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
variant="outline"
|
||||
className="w-full border-destructive/50 hover:bg-destructive/10 hover:text-destructive text-destructive"
|
||||
>
|
||||
<HardDriveDownload className="mr-2 h-4 w-4" />
|
||||
Deploy with Fresh Volumes
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
Are you absolutely sure?
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-2">
|
||||
<p>This action will:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Stop the current compose</li>
|
||||
<li>Delete all volumes and their data</li>
|
||||
<li>Redeploy the compose with empty volumes</li>
|
||||
</ul>
|
||||
<p className="font-medium text-destructive mt-4">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-fresh-volumes"
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<span>
|
||||
To confirm, type{" "}
|
||||
<Badge
|
||||
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (data?.name && data?.appName) {
|
||||
copy(expectedName);
|
||||
toast.success(
|
||||
"Copied to clipboard. Be careful!",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{expectedName}
|
||||
<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,
|
||||
composeId,
|
||||
sourceType: "bitbucket",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
|
||||
+1
@@ -97,6 +97,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
composeId,
|
||||
sourceType: "git",
|
||||
composePath: values.composePath,
|
||||
composeStatus: "idle",
|
||||
watchPaths: values.watchPaths || [],
|
||||
enableSubmodules: values.enableSubmodules,
|
||||
})
|
||||
|
||||
+1
@@ -157,6 +157,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
giteaId: data.giteaId,
|
||||
composeId,
|
||||
sourceType: "gitea",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
} as any)
|
||||
|
||||
+1
@@ -155,6 +155,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
composePath: data.composePath,
|
||||
githubId: data.githubId,
|
||||
sourceType: "github",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
triggerType: data.triggerType,
|
||||
|
||||
+1
@@ -169,6 +169,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
gitlabProjectId: data.repository.id,
|
||||
gitlabPathNamespace: data.repository.gitlabPathNamespace,
|
||||
sourceType: "gitlab",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
|
||||
@@ -554,10 +554,7 @@ export const RestoreBackup = ({
|
||||
value={field.value}
|
||||
onValueChange={(value: DatabaseType) => {
|
||||
field.onChange(value);
|
||||
form.setValue("metadata", {
|
||||
serviceName:
|
||||
form.getValues("metadata.serviceName") ?? "",
|
||||
});
|
||||
form.setValue("metadata", {});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -584,7 +581,7 @@ export const RestoreBackup = ({
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value ?? ""}
|
||||
value={field.value || undefined}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { ClipboardList, DatabaseBackup, Play, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ClipboardList,
|
||||
Database,
|
||||
DatabaseBackup,
|
||||
Play,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -95,9 +101,12 @@ export const ShowBackups = ({
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl">Backups</CardTitle>
|
||||
<CardHeader className="flex flex-row justify-between gap-4 flex-wrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Database className="size-6 text-muted-foreground" />
|
||||
Backups
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Add backups to your database to save the data to a different
|
||||
provider.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
type PaginationState,
|
||||
@@ -25,6 +27,7 @@ import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -94,21 +97,14 @@ function getServiceInfo(d: DeploymentRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ShowDeploymentsTableProps {
|
||||
globalFilter: string;
|
||||
statusFilter: string;
|
||||
typeFilter: string;
|
||||
}
|
||||
|
||||
export function ShowDeploymentsTable({
|
||||
globalFilter,
|
||||
statusFilter,
|
||||
typeFilter,
|
||||
}: ShowDeploymentsTableProps) {
|
||||
export function ShowDeploymentsTable() {
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "createdAt", desc: true },
|
||||
]);
|
||||
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 50,
|
||||
@@ -450,140 +446,177 @@ export function ShowDeploymentsTable({
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
pagination,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="px-0">
|
||||
{isLoading ? (
|
||||
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Loading deployments...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search by name, project, environment, server..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="running">Running</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value="application">Application</SelectItem>
|
||||
<SelectItem value="compose">Compose</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="px-0">
|
||||
{isLoading ? (
|
||||
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Loading deployments...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className=" text-center"
|
||||
>
|
||||
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
|
||||
<Rocket className="size-8" />
|
||||
<p className="font-medium">No deployments found</p>
|
||||
<p className="text-sm">
|
||||
Deployments from applications and compose will appear
|
||||
here.
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Rows per page
|
||||
</span>
|
||||
<Select
|
||||
value={String(pagination.pageSize)}
|
||||
onValueChange={(value) => {
|
||||
setPagination((p) => ({
|
||||
...p,
|
||||
pageSize: Number(value),
|
||||
pageIndex: 0,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 25, 50, 100].map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Showing{" "}
|
||||
{filteredData.length === 0
|
||||
? 0
|
||||
: pagination.pageIndex * pagination.pageSize + 1}{" "}
|
||||
to{" "}
|
||||
{Math.min(
|
||||
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||
filteredData.length,
|
||||
)}{" "}
|
||||
of {filteredData.length} entries
|
||||
</span>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className=" text-center"
|
||||
>
|
||||
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
|
||||
<Rocket className="size-8" />
|
||||
<p className="font-medium">No deployments found</p>
|
||||
<p className="text-sm">
|
||||
Deployments from applications and compose will
|
||||
appear here.
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Rows per page
|
||||
</span>
|
||||
<Select
|
||||
value={String(pagination.pageSize)}
|
||||
onValueChange={(value) => {
|
||||
setPagination((p) => ({
|
||||
...p,
|
||||
pageSize: Number(value),
|
||||
pageIndex: 0,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 25, 50, 100].map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Showing{" "}
|
||||
{filteredData.length === 0
|
||||
? 0
|
||||
: pagination.pageIndex * pagination.pageSize + 1}{" "}
|
||||
to{" "}
|
||||
{Math.min(
|
||||
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||
filteredData.length,
|
||||
)}{" "}
|
||||
of {filteredData.length} entries
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,19 +45,13 @@ function StatCard({
|
||||
label,
|
||||
value,
|
||||
delta,
|
||||
href,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
delta?: string;
|
||||
href?: string;
|
||||
}) {
|
||||
const content = (
|
||||
<div
|
||||
className={`rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between h-full ${
|
||||
href ? "transition-colors hover:bg-muted/40 cursor-pointer" : ""
|
||||
}`}
|
||||
>
|
||||
return (
|
||||
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between">
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
@@ -69,16 +63,6 @@ function StatCard({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className="block h-full">
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function StatusListCard({
|
||||
@@ -198,7 +182,6 @@ export const ShowHome = () => {
|
||||
label="Projects"
|
||||
value={String(totals.projects)}
|
||||
delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
|
||||
href="/dashboard/projects"
|
||||
/>
|
||||
<StatCard
|
||||
label="Services"
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { Copy, Loader2, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const CONFIRMATION_WORD = "DELETE";
|
||||
|
||||
export const DeleteAccountByEmail = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
const [record, setRecord] = useState<string | null>(null);
|
||||
const { mutateAsync, isPending } =
|
||||
api.user.deleteAccountByEmail.useMutation();
|
||||
|
||||
const canConfirm =
|
||||
email.trim().length > 0 && confirmation.trim() === CONFIRMATION_WORD;
|
||||
|
||||
const reset = () => {
|
||||
setEmail("");
|
||||
setConfirmation("");
|
||||
setRecord(null);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
const result = await mutateAsync({ email: email.trim() });
|
||||
setRecord(JSON.stringify(result, null, 2));
|
||||
toast.success(`Account ${result.email} deleted`);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error deleting account",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(value) => {
|
||||
setOpen(value);
|
||||
if (!value) reset();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive" className="gap-2">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete account
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete an account by email</DialogTitle>
|
||||
<DialogDescription>
|
||||
Cancels every Stripe subscription and deletes the user with all the
|
||||
organizations they own. Invoices stay in Stripe for accounting. Use
|
||||
it to fulfil data deletion requests.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{record ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<AlertBlock type="success">
|
||||
Deletion completed. Keep this record as evidence of the request
|
||||
being fulfilled.
|
||||
</AlertBlock>
|
||||
<pre className="text-xs bg-muted rounded-md p-3 overflow-x-auto max-h-64">
|
||||
{record}
|
||||
</pre>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 self-end"
|
||||
onClick={() => {
|
||||
copy(record);
|
||||
toast.success("Deletion record copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy record
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="delete-account-email">Account email</Label>
|
||||
<Input
|
||||
id="delete-account-email"
|
||||
type="email"
|
||||
autoComplete="off"
|
||||
placeholder="user@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="delete-account-word">
|
||||
Type <span className="font-mono">{CONFIRMATION_WORD}</span> to
|
||||
confirm
|
||||
</Label>
|
||||
<Input
|
||||
id="delete-account-word"
|
||||
autoComplete="off"
|
||||
placeholder={CONFIRMATION_WORD}
|
||||
value={confirmation}
|
||||
onChange={(e) => setConfirmation(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
{record ? (
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!canConfirm || isPending}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Delete account
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -46,7 +46,6 @@ import { authClient } from "@/lib/auth-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||
import { DeleteAccountByEmail } from "./delete-account-by-email";
|
||||
|
||||
type User = typeof authClient.$Infer.Session.user;
|
||||
|
||||
@@ -79,12 +78,12 @@ export const ImpersonationBar = () => {
|
||||
});
|
||||
|
||||
const filteredUsers = response.data?.users.filter(
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
(user) => user.allowImpersonation && data?.user?.email !== user.email,
|
||||
);
|
||||
|
||||
if (!response.error) {
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
setUsers(filteredUsers || []);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -186,7 +185,6 @@ export const ImpersonationBar = () => {
|
||||
<Logo
|
||||
className="w-10 h-10"
|
||||
logoUrl={whitelabeling?.logoUrl || undefined}
|
||||
logoLightUrl={whitelabeling?.logoLightUrl || undefined}
|
||||
/>
|
||||
{!isImpersonating ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
@@ -284,7 +282,6 @@ export const ImpersonationBar = () => {
|
||||
<Shield className="h-4 w-4" />
|
||||
Impersonate
|
||||
</Button>
|
||||
<DeleteAccountByEmail />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4 w-full flex-wrap">
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Fraunces } from "next/font/google";
|
||||
|
||||
export const displayFont = Fraunces({
|
||||
subsets: ["latin"],
|
||||
weight: ["600"],
|
||||
style: ["normal", "italic"],
|
||||
display: "swap",
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
const ONBOARDING_ACTIVE_KEY = "dokploy_onboarding_active";
|
||||
const ONBOARDING_STATE_KEY = "dokploy_onboarding_state";
|
||||
|
||||
interface OnboardingState {
|
||||
stepId?: string;
|
||||
projectId?: string;
|
||||
environmentId?: string;
|
||||
}
|
||||
|
||||
export const isOnboardingActive = () => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(ONBOARDING_ACTIVE_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const markOnboardingActive = () => {
|
||||
try {
|
||||
window.localStorage.setItem(ONBOARDING_ACTIVE_KEY, "true");
|
||||
} catch {}
|
||||
};
|
||||
|
||||
export const clearOnboardingActive = () => {
|
||||
try {
|
||||
window.localStorage.removeItem(ONBOARDING_ACTIVE_KEY);
|
||||
window.localStorage.removeItem(ONBOARDING_STATE_KEY);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
export const getOnboardingState = (): OnboardingState => {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(ONBOARDING_STATE_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const setOnboardingState = (state: OnboardingState) => {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
ONBOARDING_STATE_KEY,
|
||||
JSON.stringify({ ...getOnboardingState(), ...state }),
|
||||
);
|
||||
} catch {}
|
||||
};
|
||||
@@ -1,296 +0,0 @@
|
||||
import { defineStepper } from "@stepperize/react";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||
import { Logo } from "@/components/shared/logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
import { getOnboardingState, setOnboardingState } from "./onboarding-lock";
|
||||
import { CompleteStep } from "./steps/complete-step";
|
||||
import { DeployStep } from "./steps/deploy-step";
|
||||
import { PlanStep } from "./steps/plan-step";
|
||||
import { ProjectStep } from "./steps/project-step";
|
||||
import { ServerStep } from "./steps/server-step";
|
||||
import { WelcomeStep } from "./steps/welcome-step";
|
||||
|
||||
const { useStepper, steps, Scoped } = defineStepper(
|
||||
{ id: "welcome", title: "Welcome" },
|
||||
{ id: "plan", title: "Pick a plan" },
|
||||
{ id: "project", title: "New project" },
|
||||
{ id: "server", title: "Connect server" },
|
||||
{ id: "deploy", title: "Ship something" },
|
||||
{ id: "complete", title: "You're live" },
|
||||
);
|
||||
|
||||
type StepId = (typeof steps)[number]["id"];
|
||||
const isStepId = (id: string | undefined): id is StepId =>
|
||||
!!id && steps.some((step) => step.id === id);
|
||||
|
||||
interface Props {
|
||||
onClose: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export const OnboardingWizard = ({ onClose }: Props) => {
|
||||
const router = useRouter();
|
||||
const persisted = getOnboardingState();
|
||||
const stepper = useStepper(
|
||||
isStepId(persisted.stepId) ? persisted.stepId : undefined,
|
||||
);
|
||||
const [projectId, setProjectId] = useState<string | undefined>(
|
||||
persisted.projectId,
|
||||
);
|
||||
const [environmentId, setEnvironmentId] = useState<string | undefined>(
|
||||
persisted.environmentId,
|
||||
);
|
||||
|
||||
const { data: isCloud = true } = api.settings.isCloud.useQuery();
|
||||
const visibleStepIds = isCloud
|
||||
? steps.map((step) => step.id)
|
||||
: steps
|
||||
.filter((step) => step.id !== "plan" && step.id !== "server")
|
||||
.map((step) => step.id);
|
||||
const visibleIndex = visibleStepIds.indexOf(stepper.current.id);
|
||||
const isLastVisible = visibleIndex === visibleStepIds.length - 1;
|
||||
const goToNextVisible = () => {
|
||||
const nextId = visibleStepIds[visibleIndex + 1];
|
||||
if (nextId) stepper.goTo(nextId);
|
||||
};
|
||||
|
||||
const [skipAllOpen, setSkipAllOpen] = useState(false);
|
||||
const handleSkipAll = async () => {
|
||||
await onClose();
|
||||
router.push(
|
||||
projectId && environmentId
|
||||
? `/dashboard/project/${projectId}/environment/${environmentId}`
|
||||
: "/dashboard/projects",
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setOnboardingState({ stepId: stepper.current.id });
|
||||
}, [stepper.current.id]);
|
||||
|
||||
const { error: projectCheckError } = api.project.one.useQuery(
|
||||
{ projectId: projectId ?? "" },
|
||||
{ enabled: !!projectId, retry: false },
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!projectCheckError || !projectId) return;
|
||||
setProjectId(undefined);
|
||||
setEnvironmentId(undefined);
|
||||
setOnboardingState({ projectId: undefined, environmentId: undefined });
|
||||
stepper.goTo("project");
|
||||
}, [projectCheckError, projectId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 flex flex-col md:flex-row bg-background text-foreground">
|
||||
<aside className="relative shrink-0 md:w-[300px] lg:w-[340px] overflow-hidden bg-zinc-950 text-zinc-400">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.07]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px)",
|
||||
backgroundSize: "28px 28px",
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute -top-24 -left-24 size-72 rounded-full bg-white/10 blur-[100px]" />
|
||||
|
||||
<div className="relative flex md:h-full flex-col p-6 lg:p-8">
|
||||
<div className="flex items-center justify-between md:block">
|
||||
<div className="flex items-center gap-2.5 text-zinc-50 [&_path]:!fill-white [&_path]:!stroke-white">
|
||||
<Logo className="size-7" />
|
||||
<span className="text-lg font-semibold tracking-tight">
|
||||
Dokploy
|
||||
</span>
|
||||
</div>
|
||||
{!isLastVisible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSkipAllOpen(true)}
|
||||
className="font-mono text-[11px] uppercase tracking-wider text-zinc-400 hover:text-zinc-100 transition-colors md:hidden"
|
||||
>
|
||||
Skip all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Scoped>
|
||||
<ol className="hidden md:flex flex-col gap-0.5 mt-12">
|
||||
{visibleStepIds.map((id, index) => {
|
||||
const step = steps.find((s) => s.id === id)!;
|
||||
const isDone = index < visibleIndex;
|
||||
const isCurrent = stepper.current.id === step.id;
|
||||
return (
|
||||
<li key={step.id} className="flex gap-3.5">
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
className={`flex items-center justify-center font-mono text-[11px] tabular-nums size-5 rounded-full ${
|
||||
isCurrent
|
||||
? "bg-white text-zinc-950 font-semibold"
|
||||
: isDone
|
||||
? "text-zinc-100"
|
||||
: "text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{isDone ? (
|
||||
<CheckIcon className="size-3" />
|
||||
) : (
|
||||
String(index + 1).padStart(2, "0")
|
||||
)}
|
||||
</span>
|
||||
{index < visibleStepIds.length - 1 && (
|
||||
<span
|
||||
className={`w-px flex-1 min-h-7 my-1.5 ${
|
||||
isDone ? "bg-zinc-400" : "bg-zinc-700"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isDone ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => stepper.goTo(step.id)}
|
||||
className="text-sm font-medium transition-colors pb-6 last:pb-0 text-zinc-100 hover:text-white text-left"
|
||||
>
|
||||
{step.title}
|
||||
</button>
|
||||
) : (
|
||||
<p
|
||||
className={`text-sm font-medium transition-colors pb-6 last:pb-0 ${
|
||||
isCurrent ? "text-white" : "text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{step.title}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</Scoped>
|
||||
|
||||
<div className="hidden md:flex flex-col gap-4 mt-auto pt-8 border-t border-zinc-800">
|
||||
{!isLastVisible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSkipAllOpen(true)}
|
||||
className="w-fit font-mono text-[11px] uppercase tracking-wider text-zinc-400 hover:text-zinc-100 transition-colors"
|
||||
>
|
||||
Skip all →
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-zinc-500">
|
||||
<Link
|
||||
href="https://github.com/dokploy/dokploy"
|
||||
target="_blank"
|
||||
className="hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
<GithubIcon />
|
||||
</Link>
|
||||
<Link
|
||||
href="https://x.com/getdokploy"
|
||||
target="_blank"
|
||||
className="hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
strokeWidth="0"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="size-4"
|
||||
>
|
||||
<path d="M10.4883 14.651L15.25 21H22.25L14.3917 10.5223L20.9308 3H18.2808L13.1643 8.88578L8.75 3H1.75L9.26086 13.0145L2.31915 21H4.96917L10.4883 14.651ZM16.25 19L5.75 5H7.75L18.25 19H16.25Z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://discord.com/invite/2tBnJ3jDJc"
|
||||
target="_blank"
|
||||
className="hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 48 48"
|
||||
className="size-4"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M39.248,10.177c-2.804-1.287-5.812-2.235-8.956-2.778c-0.057-0.01-0.114,0.016-0.144,0.068 c-0.387,0.688-0.815,1.585-1.115,2.291c-3.382-0.506-6.747-0.506-10.059,0c-0.3-0.721-0.744-1.603-1.133-2.291 c-0.03-0.051-0.087-0.077-0.144-0.068c-3.143,0.541-6.15,1.489-8.956,2.778c-0.024,0.01-0.045,0.028-0.059,0.051 c-5.704,8.522-7.267,16.835-6.5,25.044c0.003,0.04,0.026,0.079,0.057,0.103c3.763,2.764,7.409,4.442,10.987,5.554 c0.057,0.017,0.118-0.003,0.154-0.051c0.846-1.156,1.601-2.374,2.248-3.656c0.038-0.075,0.002-0.164-0.076-0.194 c-1.197-0.454-2.336-1.007-3.432-1.636c-0.087-0.051-0.094-0.175-0.014-0.234c0.231-0.173,0.461-0.353,0.682-0.534 c0.04-0.033,0.095-0.04,0.142-0.019c7.201,3.288,14.997,3.288,22.113,0c0.047-0.023,0.102-0.016,0.144,0.017 c0.22,0.182,0.451,0.363,0.683,0.536c0.08,0.059,0.075,0.183-0.012,0.234c-1.096,0.641-2.236,1.182-3.434,1.634 c-0.078,0.03-0.113,0.12-0.075,0.196c0.661,1.28,1.415,2.498,2.246,3.654c0.035,0.049,0.097,0.07,0.154,0.052 c3.595-1.112,7.241-2.79,11.004-5.554c0.033-0.024,0.054-0.061,0.057-0.101c0.917-9.491-1.537-17.735-6.505-25.044 C39.293,10.205,39.272,10.187,39.248,10.177z M16.703,30.273c-2.168,0-3.954-1.99-3.954-4.435s1.752-4.435,3.954-4.435 c2.22,0,3.989,2.008,3.954,4.435C20.658,28.282,18.906,30.273,16.703,30.273z M31.324,30.273c-2.168,0-3.954-1.99-3.954-4.435 s1.752-4.435,3.954-4.435c2.22,0,3.989,2.008,3.954,4.435C35.278,28.282,33.544,30.273,31.324,30.273z"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-2xl px-6 py-14 lg:py-20">
|
||||
{stepper.switch({
|
||||
welcome: () => <WelcomeStep onNext={goToNextVisible} />,
|
||||
plan: () => <PlanStep onNext={goToNextVisible} />,
|
||||
project: () => (
|
||||
<ProjectStep
|
||||
onNext={(project) => {
|
||||
setProjectId(project.projectId);
|
||||
setEnvironmentId(project.environmentId);
|
||||
setOnboardingState({
|
||||
projectId: project.projectId,
|
||||
environmentId: project.environmentId,
|
||||
});
|
||||
goToNextVisible();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
server: () => <ServerStep onNext={goToNextVisible} />,
|
||||
deploy: () => (
|
||||
<DeployStep
|
||||
environmentId={environmentId}
|
||||
onNext={goToNextVisible}
|
||||
/>
|
||||
),
|
||||
complete: () => (
|
||||
<CompleteStep
|
||||
projectId={projectId}
|
||||
environmentId={environmentId}
|
||||
onFinish={onClose}
|
||||
/>
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={skipAllOpen} onOpenChange={setSkipAllOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Skip onboarding?</DialogTitle>
|
||||
<DialogDescription>
|
||||
If this is your first time using Dokploy, we recommend going
|
||||
through these steps – it only takes a couple of minutes and gives
|
||||
you a feel for how projects, servers, and deployments fit
|
||||
together.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSkipAllOpen(false)}>
|
||||
Continue setup
|
||||
</Button>
|
||||
<Button onClick={handleSkipAll}>Skip anyway</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,133 +0,0 @@
|
||||
import {
|
||||
BookIcon,
|
||||
DatabaseIcon,
|
||||
GitMergeIcon,
|
||||
GlobeIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import ConfettiExplosion from "react-confetti-explosion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { displayFont } from "../font";
|
||||
|
||||
interface Props {
|
||||
projectId?: string;
|
||||
environmentId?: string;
|
||||
onFinish: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: DatabaseIcon,
|
||||
title: "Databases",
|
||||
description: "Postgres, MySQL, MongoDB, Redis and more, one click away.",
|
||||
},
|
||||
{
|
||||
icon: GlobeIcon,
|
||||
title: "Custom domains",
|
||||
description: "Attach your own domains and get automatic HTTPS.",
|
||||
},
|
||||
{
|
||||
icon: GitMergeIcon,
|
||||
title: "CI/CD",
|
||||
description: "Auto-deploy on every push from GitHub, GitLab or Bitbucket.",
|
||||
},
|
||||
{
|
||||
icon: UsersIcon,
|
||||
title: "Team collaboration",
|
||||
description: "Invite teammates with fine-grained permissions.",
|
||||
},
|
||||
];
|
||||
|
||||
export const CompleteStep = ({ projectId, environmentId, onFinish }: Props) => {
|
||||
const router = useRouter();
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const [isFinishing, setIsFinishing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setShowConfetti(true);
|
||||
}, []);
|
||||
|
||||
const projectHref =
|
||||
projectId && environmentId
|
||||
? `/dashboard/project/${projectId}/environment/${environmentId}`
|
||||
: "/dashboard/projects";
|
||||
|
||||
const handleFinish = async () => {
|
||||
setIsFinishing(true);
|
||||
await onFinish();
|
||||
router.push(projectHref);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="fixed inset-x-0 top-0 flex justify-center pointer-events-none">
|
||||
{showConfetti && (
|
||||
<ConfettiExplosion
|
||||
duration={3000}
|
||||
force={0.35}
|
||||
particleCount={200}
|
||||
width={1200}
|
||||
zIndex={60}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Done
|
||||
</span>
|
||||
<h1
|
||||
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
|
||||
>
|
||||
You're all set.
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
This is just the beginning — here's some of what else you can do.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="flex flex-col divide-y">
|
||||
{features.map((feature, index) => (
|
||||
<div key={feature.title} className="flex gap-6 py-5 first:pt-0">
|
||||
<dt className="font-mono text-xs text-muted-foreground pt-1 shrink-0 w-6">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</dt>
|
||||
<dd className="flex flex-col gap-1">
|
||||
<span className="font-medium flex items-center gap-2">
|
||||
<feature.icon className="size-4 text-muted-foreground" />
|
||||
{feature.title}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{feature.description}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-fit px-8"
|
||||
isLoading={isFinishing}
|
||||
onClick={handleFinish}
|
||||
>
|
||||
Go to my project
|
||||
</Button>
|
||||
<Button variant="ghost" asChild>
|
||||
<Link
|
||||
href="https://docs.dokploy.com/docs/core"
|
||||
target="_blank"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<BookIcon size={14} />
|
||||
Read the docs
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,295 +0,0 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
CheckCircle2,
|
||||
CopyIcon,
|
||||
Loader2,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { displayFont } from "../font";
|
||||
|
||||
interface Props {
|
||||
environmentId?: string;
|
||||
onNext: () => void;
|
||||
/** Drops the onboarding wizard's display serif for callers (e.g. the
|
||||
* post-checkout welcome modal) that want the app's regular typography. */
|
||||
plainTitle?: boolean;
|
||||
}
|
||||
|
||||
const CURATED_TEMPLATES = [
|
||||
{ id: "wordpress", name: "WordPress", logo: "wordpress.png" },
|
||||
{ id: "ghost", name: "Ghost", logo: "ghost.jpeg" },
|
||||
{ id: "n8n", name: "n8n", logo: "n8n.png" },
|
||||
{ id: "uptime-kuma", name: "Uptime Kuma", logo: "uptime-kuma.png" },
|
||||
];
|
||||
|
||||
type Deploying = { kind: "app" | "template"; id: string; url: string };
|
||||
|
||||
export const DeployStep = ({ environmentId, onNext, plainTitle }: Props) => {
|
||||
const titleClassName = plainTitle
|
||||
? "text-xl font-semibold tracking-tight"
|
||||
: `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`;
|
||||
const { data: isCloud = true } = api.settings.isCloud.useQuery();
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const serverId = servers?.[0]?.serverId;
|
||||
|
||||
const [deploying, setDeploying] = useState<Deploying | null>(null);
|
||||
const [starting, setStarting] = useState<string | null>(null);
|
||||
|
||||
const { mutateAsync: deployNginx } =
|
||||
api.application.deployNginxQuickstart.useMutation();
|
||||
const { mutateAsync: deployTemplate } =
|
||||
api.compose.deployTemplate.useMutation();
|
||||
const { mutateAsync: deployCompose } = api.compose.deploy.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: application } = api.application.one.useQuery(
|
||||
{ applicationId: deploying?.id ?? "" },
|
||||
{
|
||||
enabled: deploying?.kind === "app",
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.applicationStatus;
|
||||
return status === "done" || status === "error" ? false : 2500;
|
||||
},
|
||||
},
|
||||
);
|
||||
const { data: compose } = api.compose.one.useQuery(
|
||||
{ composeId: deploying?.id ?? "" },
|
||||
{
|
||||
enabled: deploying?.kind === "template",
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.composeStatus;
|
||||
return status === "done" || status === "error" ? false : 2500;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const status =
|
||||
deploying?.kind === "app"
|
||||
? application?.applicationStatus
|
||||
: compose?.composeStatus;
|
||||
|
||||
if (!environmentId || (isCloud && !serverId)) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
First deploy
|
||||
</span>
|
||||
<h1 className={titleClassName}>Deploy something.</h1>
|
||||
</div>
|
||||
<AlertBlock type="info" className="max-w-md">
|
||||
{isCloud
|
||||
? "You'll need a project and a connected server before deploying — you can do that anytime from the dashboard."
|
||||
: "You'll need a project before deploying — you can do that anytime from the dashboard."}
|
||||
</AlertBlock>
|
||||
<Button onClick={onNext} className="w-fit px-8">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleNginx = async () => {
|
||||
setStarting("nginx");
|
||||
try {
|
||||
const res = await deployNginx({ environmentId, serverId });
|
||||
setDeploying({ kind: "app", id: res.applicationId, url: res.domainUrl });
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error deploying the app",
|
||||
);
|
||||
} finally {
|
||||
setStarting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTemplate = async (id: string) => {
|
||||
setStarting(id);
|
||||
try {
|
||||
const composeResult = await deployTemplate({
|
||||
environmentId,
|
||||
serverId,
|
||||
id,
|
||||
});
|
||||
await deployCompose({ composeId: composeResult.composeId });
|
||||
const domains = await utils.domain.byComposeId.fetch({
|
||||
composeId: composeResult.composeId,
|
||||
});
|
||||
const host = domains?.[0]?.host;
|
||||
setDeploying({
|
||||
kind: "template",
|
||||
id: composeResult.composeId,
|
||||
url: host ? `http://${host}` : "",
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error deploying the template",
|
||||
);
|
||||
} finally {
|
||||
setStarting(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (deploying) {
|
||||
const isDone = status === "done";
|
||||
const isError = status === "error";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span
|
||||
className={`font-mono text-xs uppercase tracking-[0.2em] flex items-center gap-2 ${
|
||||
isError ? "text-destructive" : "text-primary"
|
||||
}`}
|
||||
>
|
||||
{isDone ? (
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
) : isError ? (
|
||||
<XCircleIcon className="size-3.5" />
|
||||
) : (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
)}
|
||||
{isDone ? "Live" : isError ? "Deploy failed" : "Deploying"}
|
||||
</span>
|
||||
<h1 className={titleClassName}>
|
||||
{isDone
|
||||
? "It's live."
|
||||
: isError
|
||||
? "Something went wrong."
|
||||
: "Building your app..."}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
{isDone
|
||||
? "Your app is up and reachable at:"
|
||||
: isError
|
||||
? "The deployment failed — you can check the logs from the dashboard later."
|
||||
: "This usually takes a minute or two the first time."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isDone && deploying.url ? (
|
||||
<div className="flex flex-col rounded-2xl border overflow-hidden max-w-md">
|
||||
<div className="flex items-center gap-2 px-5 py-3 border-b bg-muted/30">
|
||||
<span className="size-2 rounded-full bg-green-500 shrink-0" />
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
Reachable now
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 p-5">
|
||||
<span className="font-mono text-sm truncate">
|
||||
{deploying.url}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
copy(deploying.url);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<a href={deploying.url} target="_blank" rel="noreferrer">
|
||||
<ArrowUpRightIcon className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !isDone && !isError ? (
|
||||
<div className="rounded-2xl border p-5 max-w-md">
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin shrink-0" />
|
||||
Waiting for the build to finish...
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
onClick={onNext}
|
||||
className="w-fit px-8"
|
||||
disabled={!isDone && !isError}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
First deploy
|
||||
</span>
|
||||
<h1 className={titleClassName}>Ship something.</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
Pick a quickstart — we'll generate a domain and deploy it for you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border rounded-2xl overflow-hidden border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNginx}
|
||||
disabled={!!starting}
|
||||
className="flex flex-col items-start justify-between gap-8 bg-background p-7 text-left hover:bg-muted/40 transition-colors disabled:opacity-60"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">Simple app</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
A "Hello World" demo app, live in seconds.
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-mono text-xs uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
||||
{starting === "nginx" ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
"Deploy now →"
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col justify-between gap-8 bg-background p-7">
|
||||
<div>
|
||||
<p className="font-medium">Docker Compose template</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Popular open source stacks, one click away.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{CURATED_TEMPLATES.map((template) => (
|
||||
<Button
|
||||
key={template.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!!starting}
|
||||
isLoading={starting === template.id}
|
||||
onClick={() => handleTemplate(template.id)}
|
||||
className="justify-start"
|
||||
>
|
||||
<img
|
||||
src={`https://templates.dokploy.com/blueprints/${template.id}/${template.logo}`}
|
||||
alt={template.name}
|
||||
className="size-4 object-contain shrink-0"
|
||||
/>
|
||||
{template.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,188 +0,0 @@
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { ArrowRightIcon, CheckIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
calculatePriceHobby,
|
||||
calculatePriceStartup,
|
||||
STARTUP_SERVERS_INCLUDED,
|
||||
} from "@/components/dashboard/settings/billing/show-billing";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { displayFont } from "../font";
|
||||
|
||||
const stripePromise = loadStripe(
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
|
||||
);
|
||||
|
||||
type Tier = "hobby" | "startup";
|
||||
|
||||
const PLANS: {
|
||||
tier: Tier;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
features: string[];
|
||||
recommended?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
tier: "hobby",
|
||||
name: "Hobby",
|
||||
description: "For individual developers",
|
||||
price: calculatePriceHobby(1, false),
|
||||
features: [
|
||||
"Setup 1 server",
|
||||
"Unlimited apps & databases",
|
||||
"2 environments",
|
||||
"Community support",
|
||||
],
|
||||
},
|
||||
{
|
||||
tier: "startup",
|
||||
name: "Startup",
|
||||
description: "For small to mid-size teams",
|
||||
price: calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false),
|
||||
recommended: true,
|
||||
features: [
|
||||
`Setup up to ${STARTUP_SERVERS_INCLUDED} servers`,
|
||||
"Unlimited users & environments",
|
||||
"Basic RBAC + 2FA",
|
||||
"Email & chat support",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export const PlanStep = ({ onNext }: Props) => {
|
||||
const [loading, setLoading] = useState<
|
||||
`${"trial" | "checkout"}:${Tier}` | null
|
||||
>(null);
|
||||
const { data } = api.stripe.getProducts.useQuery();
|
||||
const { mutateAsync: createCheckoutSession } =
|
||||
api.stripe.createCheckoutSession.useMutation();
|
||||
const { mutateAsync: startFreeTrial } =
|
||||
api.stripe.startFreeTrial.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const handleCheckout = async (tier: Tier) => {
|
||||
if (!data) return;
|
||||
const productId =
|
||||
tier === "hobby" ? data.hobbyProductId : data.startupProductId;
|
||||
if (!productId) return;
|
||||
setLoading(`checkout:${tier}`);
|
||||
try {
|
||||
const stripe = await stripePromise;
|
||||
const session = await createCheckoutSession({
|
||||
tier,
|
||||
productId,
|
||||
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
|
||||
isAnnual: false,
|
||||
});
|
||||
await stripe?.redirectToCheckout({ sessionId: session.sessionId });
|
||||
} catch {
|
||||
toast.error("Error starting checkout");
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrial = async (tier: Tier) => {
|
||||
setLoading(`trial:${tier}`);
|
||||
try {
|
||||
await startFreeTrial({ tier });
|
||||
await utils.project.onboardingStatus.invalidate();
|
||||
toast.success("Your 7-day trial has started");
|
||||
onNext();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error starting trial",
|
||||
);
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Billing
|
||||
</span>
|
||||
<h1
|
||||
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
|
||||
>
|
||||
Start free, upgrade when ready.
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
Try any plan free for 7 days. No credit card required — add one only
|
||||
if you decide to stay.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border rounded-2xl overflow-hidden border">
|
||||
{PLANS.map((plan) => {
|
||||
const productId =
|
||||
plan.tier === "hobby"
|
||||
? data?.hobbyProductId
|
||||
: data?.startupProductId;
|
||||
return (
|
||||
<div
|
||||
key={plan.tier}
|
||||
className="flex flex-col justify-between gap-6 bg-background p-7"
|
||||
>
|
||||
<div>
|
||||
{plan.recommended && (
|
||||
<span className="inline-flex items-center rounded-full bg-primary text-primary-foreground font-mono text-[10px] font-semibold uppercase tracking-[0.15em] px-2.5 py-1 mb-3">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
<p className="font-medium">{plan.name}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{plan.description}
|
||||
</p>
|
||||
<p className="text-3xl font-semibold mt-4 tabular-nums">
|
||||
${plan.price.toFixed(2)}
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{" "}
|
||||
/mo
|
||||
</span>
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5 mt-4">
|
||||
{plan.features.map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckIcon className="size-3.5 shrink-0" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
isLoading={loading === `trial:${plan.tier}`}
|
||||
disabled={loading !== null}
|
||||
onClick={() => handleTrial(plan.tier)}
|
||||
>
|
||||
Start 7-day free trial
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
isLoading={loading === `checkout:${plan.tier}`}
|
||||
disabled={loading !== null || !productId}
|
||||
onClick={() => handleCheckout(plan.tier)}
|
||||
>
|
||||
Subscribe now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,118 +0,0 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { displayFont } from "../font";
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
type Schema = z.infer<typeof schema>;
|
||||
|
||||
interface Props {
|
||||
onNext: (project: { projectId: string; environmentId: string }) => void;
|
||||
/** Drops the onboarding wizard's display serif for callers (e.g. the
|
||||
* post-checkout welcome modal) that want the app's regular typography. */
|
||||
plainTitle?: boolean;
|
||||
}
|
||||
|
||||
export const ProjectStep = ({ onNext, plainTitle }: Props) => {
|
||||
const titleClassName = plainTitle
|
||||
? "text-xl font-semibold tracking-tight"
|
||||
: `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`;
|
||||
const { mutateAsync, isPending } = api.project.create.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: { name: "My First Project", description: "" },
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: Schema) => {
|
||||
try {
|
||||
const result = await mutateAsync(data);
|
||||
await utils.project.all.invalidate();
|
||||
toast.success("Project created");
|
||||
onNext({
|
||||
projectId: result.project.projectId,
|
||||
environmentId: result.environment?.environmentId ?? "",
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error creating the project",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Workspace
|
||||
</span>
|
||||
<h1 className={titleClassName}>Create your first project.</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
Projects group your apps, databases, and environments together.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-5 max-w-sm"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My First Project" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="What is this project for?"
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isPending}
|
||||
className="w-fit px-8 mt-2"
|
||||
>
|
||||
Create project
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,362 +0,0 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import copy from "copy-to-clipboard";
|
||||
import {
|
||||
CheckCircle2,
|
||||
CopyIcon,
|
||||
InfoIcon,
|
||||
Loader2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { displayFont } from "../font";
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
ipAddress: z
|
||||
.string()
|
||||
.min(1, "IP address is required")
|
||||
.refine((value) => !/\s/.test(value), "IP address cannot contain spaces"),
|
||||
});
|
||||
type Schema = z.infer<typeof schema>;
|
||||
|
||||
interface Props {
|
||||
onNext: () => void;
|
||||
/** Drops the onboarding wizard's display serif for callers (e.g. the
|
||||
* post-checkout welcome modal) that want the app's regular typography. */
|
||||
plainTitle?: boolean;
|
||||
}
|
||||
|
||||
const StatusRow = ({
|
||||
label,
|
||||
ok,
|
||||
pending,
|
||||
}: {
|
||||
label: string;
|
||||
ok?: boolean;
|
||||
pending?: boolean;
|
||||
}) => (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{ok ? (
|
||||
<CheckCircle2 className="size-4 text-green-600 dark:text-green-500 shrink-0" />
|
||||
) : pending ? (
|
||||
<Loader2 className="size-4 text-muted-foreground shrink-0 animate-spin" />
|
||||
) : (
|
||||
<XCircle className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className={ok ? "" : "text-muted-foreground"}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ServerStep = ({ onNext, plainTitle }: Props) => {
|
||||
const titleClassName = plainTitle
|
||||
? "text-xl font-semibold tracking-tight"
|
||||
: `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`;
|
||||
|
||||
const { data: sshKeys, refetch: refetchSSHKeys } = api.sshKey.all.useQuery();
|
||||
const generateSSHKey = api.sshKey.generate.useMutation();
|
||||
const createSSHKey = api.sshKey.create.useMutation();
|
||||
const hasCreatedKey = useRef(false);
|
||||
|
||||
const cloudSSHKey = sshKeys?.find(
|
||||
(sshKey) => sshKey.name === "dokploy-onboarding-ssh-key",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const ensureKey = async () => {
|
||||
if (!sshKeys || cloudSSHKey || hasCreatedKey.current) return;
|
||||
hasCreatedKey.current = true;
|
||||
try {
|
||||
const keys = await generateSSHKey.mutateAsync({ type: "rsa" });
|
||||
await createSSHKey.mutateAsync({
|
||||
name: "dokploy-onboarding-ssh-key",
|
||||
description: "Used during onboarding",
|
||||
privateKey: keys.privateKey,
|
||||
publicKey: keys.publicKey,
|
||||
organizationId: "",
|
||||
});
|
||||
await refetchSSHKeys();
|
||||
} catch {
|
||||
hasCreatedKey.current = false;
|
||||
}
|
||||
};
|
||||
ensureKey();
|
||||
}, [sshKeys]);
|
||||
|
||||
const { data: existingServers } = api.server.all.useQuery();
|
||||
const [createdServerId, setCreatedServerId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!createdServerId && existingServers && existingServers.length > 0) {
|
||||
setCreatedServerId(existingServers[0]!.serverId);
|
||||
}
|
||||
}, [existingServers, createdServerId]);
|
||||
|
||||
const { data: canCreateMoreServers } =
|
||||
api.stripe.canCreateMoreServers.useQuery();
|
||||
const { mutateAsync: createServer, isPending: isCreating } =
|
||||
api.server.create.useMutation();
|
||||
const checkIsReady = (validation?: {
|
||||
docker?: { enabled?: boolean };
|
||||
isDokployNetworkInstalled?: boolean;
|
||||
}) =>
|
||||
!!validation?.docker?.enabled && !!validation?.isDokployNetworkInstalled;
|
||||
|
||||
const {
|
||||
data: validation,
|
||||
refetch: refetchValidation,
|
||||
isFetching: isValidating,
|
||||
} = api.server.validate.useQuery(
|
||||
{ serverId: createdServerId ?? "" },
|
||||
{
|
||||
enabled: !!createdServerId,
|
||||
refetchInterval: (query) =>
|
||||
checkIsReady(query.state.data) ? false : 5000,
|
||||
},
|
||||
);
|
||||
const isReady = checkIsReady(validation);
|
||||
|
||||
const [isSettingUp, setIsSettingUp] = useState(false);
|
||||
const hasStartedSetup = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (createdServerId && !hasStartedSetup.current && !isReady) {
|
||||
hasStartedSetup.current = true;
|
||||
setIsSettingUp(true);
|
||||
}
|
||||
}, [createdServerId, isReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSettingUp) return;
|
||||
const timeout = setTimeout(() => {
|
||||
setIsSettingUp(false);
|
||||
toast.error(
|
||||
"Setup is taking too long — check the server is reachable, then try again.",
|
||||
);
|
||||
}, 120_000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isSettingUp]);
|
||||
|
||||
api.server.setupWithLogs.useSubscription(
|
||||
{ serverId: createdServerId ?? "" },
|
||||
{
|
||||
enabled: !!createdServerId && isSettingUp,
|
||||
onData(log) {
|
||||
if (typeof log === "string" && log.includes("Setup Server:")) {
|
||||
setIsSettingUp(false);
|
||||
refetchValidation();
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
setIsSettingUp(false);
|
||||
toast.error("Error setting up the server");
|
||||
console.error(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: { name: "My First Server", ipAddress: "" },
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: Schema) => {
|
||||
if (!cloudSSHKey) {
|
||||
toast.error("Still generating your SSH key, try again in a moment");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const server = await createServer({
|
||||
name: data.name,
|
||||
description: "",
|
||||
ipAddress: data.ipAddress.trim(),
|
||||
port: 22,
|
||||
username: "root",
|
||||
sshKeyId: cloudSSHKey.sshKeyId,
|
||||
serverType: "deploy",
|
||||
enableDockerCleanup: false,
|
||||
});
|
||||
setCreatedServerId(server.serverId);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error creating the server",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Infrastructure
|
||||
</span>
|
||||
<h1 className={titleClassName}>Connect a server.</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
Dokploy deploys to servers you own. Buy one from any VPS provider
|
||||
(Hetzner, DigitalOcean, Hostinger...) and paste its IP below.
|
||||
</p>
|
||||
<div className="flex items-start gap-2.5 rounded-lg border p-3 max-w-md">
|
||||
<InfoIcon className="size-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">Requirements</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
To ensure a smooth experience with Dokploy, your server should
|
||||
have at least 1GB of RAM and 30GB of disk space.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!createdServerId ? (
|
||||
<div className="flex flex-col gap-4 max-w-sm">
|
||||
{canCreateMoreServers === false && (
|
||||
<AlertBlock type="warning">
|
||||
You'll need a plan or trial before connecting a server — go back
|
||||
to the "Pick a plan" step.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
1. Run this on your server to authorize Dokploy
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 min-w-0 truncate rounded bg-muted px-2 py-1.5 text-xs">
|
||||
{cloudSSHKey
|
||||
? `echo "${cloudSSHKey.publicKey}" >> ~/.ssh/authorized_keys`
|
||||
: "Generating..."}
|
||||
</code>
|
||||
{cloudSSHKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
copy(
|
||||
`echo "${cloudSSHKey.publicKey}" >> ~/.ssh/authorized_keys`,
|
||||
);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My First Server" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ipAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>2. IP address</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="192.168.1.100" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isCreating}
|
||||
disabled={!cloudSSHKey || canCreateMoreServers === false}
|
||||
className="mt-2"
|
||||
>
|
||||
Connect server
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 w-full max-w-sm">
|
||||
<div className="flex flex-col gap-3 rounded-lg border p-4">
|
||||
{isSettingUp ? (
|
||||
<div className="flex items-center justify-center gap-3 py-4 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin shrink-0" />
|
||||
Setting up your server – installing Docker and dependencies…
|
||||
</div>
|
||||
) : isValidating && !validation ? (
|
||||
<div className="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Checking your server...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<StatusRow
|
||||
label="Docker installed"
|
||||
ok={validation?.docker?.enabled}
|
||||
pending={!validation?.docker?.enabled && !isReady}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Dokploy network created"
|
||||
ok={validation?.isDokployNetworkInstalled}
|
||||
pending={!validation?.isDokployNetworkInstalled && !isReady}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isReady && !isSettingUp && (
|
||||
<AlertBlock type="info">
|
||||
Setup runs after the server boots and this page checks for it
|
||||
automatically. You can retry setup, or use "Skip for now" above to
|
||||
continue and come back to this later.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
isLoading={isValidating || isSettingUp}
|
||||
onClick={() => {
|
||||
if (!isReady && !isSettingUp) {
|
||||
setIsSettingUp(true);
|
||||
} else {
|
||||
refetchValidation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!isReady && !isSettingUp ? "Retry setup" : "Check now"}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={!isReady || isSettingUp}
|
||||
onClick={onNext}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
import { GitBranchIcon, PuzzleIcon, ServerIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { displayFont } from "../font";
|
||||
|
||||
interface Props {
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
const points = [
|
||||
{
|
||||
icon: GitBranchIcon,
|
||||
title: "Deploy from Git or Docker",
|
||||
description:
|
||||
"Push a repo, a compose file, or a container image and go live.",
|
||||
},
|
||||
{
|
||||
icon: PuzzleIcon,
|
||||
title: "One-click templates",
|
||||
description:
|
||||
"WordPress, databases, and dozens of open source apps, pre-wired.",
|
||||
},
|
||||
{
|
||||
icon: ServerIcon,
|
||||
title: "Your own servers",
|
||||
description: "Runs on infrastructure you control — no vendor lock-in.",
|
||||
},
|
||||
];
|
||||
|
||||
export const WelcomeStep = ({ onNext }: Props) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-12">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Welcome
|
||||
</span>
|
||||
<h1
|
||||
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
|
||||
>
|
||||
Let's get your first app <em className="not-italic">live</em>.
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
It just takes a few steps: pick a plan, connect a server, ship
|
||||
something. You'll have a working URL by the end.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="flex flex-col divide-y">
|
||||
{points.map((point, index) => (
|
||||
<div key={point.title} className="flex gap-6 py-5 first:pt-0">
|
||||
<dt className="font-mono text-xs text-muted-foreground pt-1 shrink-0 w-6">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</dt>
|
||||
<dd className="flex flex-col gap-1">
|
||||
<span className="font-medium flex items-center gap-2">
|
||||
<point.icon className="size-4 text-muted-foreground" />
|
||||
{point.title}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{point.description}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<Button size="lg" onClick={onNext} className="w-fit px-8">
|
||||
Get started
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,9 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
|
||||
import { PenBoxIcon, Plus, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { PenBoxIcon, Plus } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Logo } from "@/components/shared/logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -16,7 +14,6 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dropzone } from "@/components/ui/dropzone";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -27,8 +24,6 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { resizeImage } from "@/utils/image-processing";
|
||||
import { sanitizeSvg } from "@/utils/sanitize-svg";
|
||||
|
||||
const organizationSchema = z.object({
|
||||
name: z.string().min(1, {
|
||||
@@ -42,24 +37,10 @@ type OrganizationFormValues = z.infer<typeof organizationSchema>;
|
||||
interface Props {
|
||||
organizationId?: string;
|
||||
children?: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function AddOrganization({
|
||||
organizationId,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
}: Props) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const uploadCounter = useRef(0);
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
const setOpen = isControlled
|
||||
? controlledOnOpenChange || (() => {})
|
||||
: setInternalOpen;
|
||||
export function AddOrganization({ organizationId }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const { data: organization } = api.organization.one.useQuery(
|
||||
{
|
||||
@@ -84,18 +65,14 @@ export function AddOrganization({
|
||||
|
||||
useEffect(() => {
|
||||
if (organization) {
|
||||
uploadCounter.current++;
|
||||
setIsUploading(false);
|
||||
form.reset({
|
||||
name: organization.name,
|
||||
logo: organization.logo || "",
|
||||
});
|
||||
setUploadedFileName(null);
|
||||
}
|
||||
}, [organization, form]);
|
||||
|
||||
const onSubmit = async (values: OrganizationFormValues) => {
|
||||
if (isUploading) return;
|
||||
await mutateAsync({
|
||||
name: values.name,
|
||||
logo: values.logo,
|
||||
@@ -103,7 +80,6 @@ export function AddOrganization({
|
||||
})
|
||||
.then(() => {
|
||||
form.reset();
|
||||
setUploadedFileName(null);
|
||||
toast.success(
|
||||
`Organization ${organizationId ? "updated" : "created"} successfully`,
|
||||
);
|
||||
@@ -123,94 +99,8 @@ export function AddOrganization({
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileUpload = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
|
||||
const currentUploadId = ++uploadCounter.current;
|
||||
setIsUploading(true);
|
||||
|
||||
const allowedTypes = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
"image/webp",
|
||||
];
|
||||
const fileExtension = file.name.split(".").pop()?.toLowerCase();
|
||||
const allowedExtensions = ["jpg", "jpeg", "png", "svg", "webp"];
|
||||
|
||||
if (
|
||||
!allowedTypes.includes(file.type) &&
|
||||
!allowedExtensions.includes(fileExtension || "")
|
||||
) {
|
||||
toast.error("Only JPG, JPEG, PNG, WEBP, and SVG files are allowed");
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error("Image size must be less than 2MB");
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const isSvg = file.type === "image/svg+xml" || fileExtension === "svg";
|
||||
|
||||
if (isSvg) {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const sanitizedDataUrl = sanitizeSvg(text);
|
||||
if (currentUploadId !== uploadCounter.current) return;
|
||||
if (!sanitizedDataUrl) {
|
||||
toast.error("Invalid SVG file");
|
||||
return;
|
||||
}
|
||||
form.setValue("logo", sanitizedDataUrl);
|
||||
form.trigger("logo");
|
||||
setUploadedFileName(file.name);
|
||||
} catch (error) {
|
||||
if (currentUploadId === uploadCounter.current) {
|
||||
toast.error("Error processing SVG");
|
||||
}
|
||||
} finally {
|
||||
if (currentUploadId === uploadCounter.current) {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Resize raster images to max 256x256 and convert to WebP to save space
|
||||
try {
|
||||
const resizedDataUrl = await resizeImage(file, 256);
|
||||
if (currentUploadId !== uploadCounter.current) return;
|
||||
form.setValue("logo", resizedDataUrl);
|
||||
form.trigger("logo");
|
||||
setUploadedFileName(file.name);
|
||||
} catch (error) {
|
||||
if (currentUploadId === uploadCounter.current) {
|
||||
toast.error("Error processing image");
|
||||
}
|
||||
} finally {
|
||||
if (currentUploadId === uploadCounter.current) {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
if (!val) {
|
||||
uploadCounter.current++;
|
||||
setIsUploading(false);
|
||||
}
|
||||
setOpen(val);
|
||||
}}
|
||||
>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{organizationId ? (
|
||||
<Button
|
||||
@@ -256,10 +146,8 @@ export function AddOrganization({
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="items-center gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel className="text-right">Name</FormLabel>
|
||||
</div>
|
||||
<FormItem className="tems-center gap-4">
|
||||
<FormLabel className="text-right">Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Organization name"
|
||||
@@ -274,78 +162,23 @@ export function AddOrganization({
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logo"
|
||||
render={({ field }) => {
|
||||
const isDataUrl = field.value?.startsWith("data:");
|
||||
const displayValue = isDataUrl
|
||||
? uploadedFileName || "Uploaded image"
|
||||
: field.value || "";
|
||||
|
||||
return (
|
||||
<FormItem className="gap-4">
|
||||
<FormLabel className="text-right">
|
||||
Logo URL or Upload
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="col-span-3 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-md border bg-muted/50 overflow-hidden">
|
||||
{field.value ? (
|
||||
// biome-ignore lint/performance/noImgElement: user uploaded logo preview
|
||||
<img
|
||||
src={field.value}
|
||||
alt="Logo preview"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Logo className="size-7" />
|
||||
)}
|
||||
</div>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
placeholder="https://example.com/logo.png"
|
||||
{...field}
|
||||
value={displayValue}
|
||||
readOnly={isDataUrl}
|
||||
onChange={(e) => {
|
||||
uploadCounter.current++;
|
||||
setIsUploading(false);
|
||||
field.onChange(e);
|
||||
if (isDataUrl) setUploadedFileName(null);
|
||||
}}
|
||||
className="w-full pr-8"
|
||||
/>
|
||||
{field.value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
uploadCounter.current++;
|
||||
setIsUploading(false);
|
||||
form.setValue("logo", "");
|
||||
setUploadedFileName(null);
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Dropzone
|
||||
dropMessage="Drag & drop a logo or click to upload"
|
||||
accept=".jpg,.jpeg,.png,.svg,.webp,image/jpeg,image/png,image/svg+xml,image/webp"
|
||||
onChange={handleFileUpload}
|
||||
classNameWrapper="border-2 border-dashed border-border hover:border-primary bg-muted/30 hover:bg-muted/50 transition-all rounded-lg"
|
||||
classNameContent="h-32"
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage className="col-span-3 col-start-2" />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<FormItem className="gap-4">
|
||||
<FormLabel className="text-right">Logo URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/logo.png"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="col-span-3 col-start-2" />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="submit" isLoading={isPending || isUploading}>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{organizationId ? "Update organization" : "Create organization"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Rocket } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
|
||||
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
|
||||
import {
|
||||
@@ -9,14 +8,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
const SUBTAB_VALUES = ["deployments", "queue"] as const;
|
||||
@@ -35,10 +26,6 @@ export const ShowOverviewDeployments = () => {
|
||||
? router.query.subtab
|
||||
: DEFAULT_SUBTAB;
|
||||
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
|
||||
const setSubtab = (value: string) => {
|
||||
if (!isValidSubtab(value)) return;
|
||||
const { subtab: _current, ...query } = router.query;
|
||||
@@ -72,50 +59,12 @@ export const ShowOverviewDeployments = () => {
|
||||
onValueChange={setSubtab}
|
||||
className="w-full min-w-0"
|
||||
>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-y-4 gap-x-3 mt-2">
|
||||
<TabsList className="self-start shrink-0">
|
||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||
<TabsTrigger value="queue">Queue</TabsTrigger>
|
||||
</TabsList>
|
||||
{subtab === "deployments" && (
|
||||
<div className="flex flex-wrap lg:flex-nowrap items-center gap-2 lg:ml-auto">
|
||||
<Input
|
||||
placeholder="Search by name, project, environment, server..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="running">Running</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value="application">Application</SelectItem>
|
||||
<SelectItem value="compose">Compose</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TabsList className="mt-2">
|
||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||
<TabsTrigger value="queue">Queue</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="deployments" className="mt-0 min-w-0 pt-4">
|
||||
<ShowDeploymentsTable
|
||||
globalFilter={globalFilter}
|
||||
statusFilter={statusFilter}
|
||||
typeFilter={typeFilter}
|
||||
/>
|
||||
<ShowDeploymentsTable />
|
||||
</TabsContent>
|
||||
<TabsContent value="queue" className="mt-0 pt-4">
|
||||
<ShowQueueTable />
|
||||
|
||||
@@ -435,7 +435,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
{viewMode === "detailed" &&
|
||||
template?.tags?.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{template.tags.slice(0, 6).map((tag) => (
|
||||
{template?.tags?.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="green"
|
||||
@@ -444,15 +444,6 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{template.tags.length > 6 && (
|
||||
<Badge
|
||||
variant="green"
|
||||
className="text-[10px] px-2 py-0"
|
||||
title={template.tags.slice(6).join(", ")}
|
||||
>
|
||||
+{template.tags.length - 6}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
|
||||
import { VaultImportDialog } from "@/components/shared/vault-import-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -157,17 +156,7 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
||||
name="env"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Environment variables</FormLabel>
|
||||
<VaultImportDialog
|
||||
projectId={data?.projectId}
|
||||
environmentId={environmentId}
|
||||
currentEnv={field.value ?? ""}
|
||||
onImport={(next) =>
|
||||
form.setValue("env", next, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormLabel>Environment variables</FormLabel>
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
completionSource={completionSource}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { useEnvCompletionSource } from "@/components/shared/env-autocomplete";
|
||||
import { VaultImportDialog } from "@/components/shared/vault-import-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -153,16 +152,7 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
|
||||
name="env"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Environment variables</FormLabel>
|
||||
<VaultImportDialog
|
||||
projectId={projectId}
|
||||
currentEnv={field.value ?? ""}
|
||||
onImport={(next) =>
|
||||
form.setValue("env", next, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormLabel>Environment variables</FormLabel>
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
completionSource={completionSource}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
CheckIcon,
|
||||
Clock,
|
||||
CreditCard,
|
||||
FileText,
|
||||
Loader2,
|
||||
@@ -94,7 +93,6 @@ export const ShowBilling = () => {
|
||||
const router = useRouter();
|
||||
const { data: servers } = api.server.count.useQuery();
|
||||
const { data: admin } = api.user.get.useQuery();
|
||||
const { data: billingStatus } = api.stripe.getBillingStatus.useQuery();
|
||||
const { data, isPending } = api.stripe.getProducts.useQuery();
|
||||
const { mutateAsync: createCheckoutSession } =
|
||||
api.stripe.createCheckoutSession.useMutation();
|
||||
@@ -105,67 +103,7 @@ export const ShowBilling = () => {
|
||||
api.stripe.upgradeSubscription.useMutation();
|
||||
const { mutateAsync: updateInvoiceNotifications } =
|
||||
api.stripe.updateInvoiceNotifications.useMutation();
|
||||
const { mutateAsync: startFreeTrial } =
|
||||
api.stripe.startFreeTrial.useMutation();
|
||||
const utils = api.useUtils();
|
||||
const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null);
|
||||
const [switchingTrial, setSwitchingTrial] = useState(false);
|
||||
|
||||
const handleSwitchTrialPlan = async (
|
||||
tier: "hobby" | "startup",
|
||||
isAnnualPlan: boolean,
|
||||
label?: string,
|
||||
) => {
|
||||
const planName =
|
||||
label ??
|
||||
`${tier === "startup" ? "Startup" : "Hobby"} ${isAnnualPlan ? "annual" : "monthly"}`;
|
||||
|
||||
const switchPlan = async () => {
|
||||
await upgradeSubscription({
|
||||
tier,
|
||||
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
|
||||
isAnnual: isAnnualPlan,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
await Promise.all([
|
||||
utils.stripe.getBillingStatus.invalidate(),
|
||||
utils.stripe.getProducts.invalidate(),
|
||||
utils.user.get.invalidate(),
|
||||
utils.server.count.invalidate(),
|
||||
]);
|
||||
};
|
||||
|
||||
setSwitchingTrial(true);
|
||||
await toast
|
||||
.promise(switchPlan(), {
|
||||
loading: `Switching to ${planName}...`,
|
||||
success: `Your trial is now on ${planName}`,
|
||||
error: (error) =>
|
||||
error instanceof Error ? error.message : "Error switching plan",
|
||||
})
|
||||
.unwrap()
|
||||
.catch(() => undefined);
|
||||
setSwitchingTrial(false);
|
||||
};
|
||||
|
||||
const handleStartTrial = async (tier: "hobby" | "startup") => {
|
||||
setTrialTier(tier);
|
||||
try {
|
||||
await startFreeTrial({ tier });
|
||||
await Promise.all([
|
||||
utils.stripe.getBillingStatus.invalidate(),
|
||||
utils.stripe.getProducts.invalidate(),
|
||||
utils.user.get.invalidate(),
|
||||
]);
|
||||
toast.success("Your 7-day trial has started");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error starting trial",
|
||||
);
|
||||
} finally {
|
||||
setTrialTier(null);
|
||||
}
|
||||
};
|
||||
|
||||
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
||||
const [startupServerQuantity, setStartupServerQuantity] = useState(
|
||||
@@ -212,23 +150,12 @@ export const ShowBilling = () => {
|
||||
|
||||
const useNewPricing = data?.hobbyProductId && data?.startupProductId;
|
||||
const products = data?.products.filter((product) => {
|
||||
// @ts-expect-error
|
||||
// @ts-ignore
|
||||
const interval = product?.default_price?.recurring?.interval;
|
||||
return isAnnual ? interval === "year" : interval === "month";
|
||||
});
|
||||
|
||||
const isEnterpriseCloud = admin?.user.isEnterpriseCloud ?? false;
|
||||
const canStartTrial =
|
||||
!isEnterpriseCloud &&
|
||||
!!billingStatus &&
|
||||
!billingStatus.plan &&
|
||||
!billingStatus.isOnTrial &&
|
||||
!billingStatus.hasUsedTrial;
|
||||
/** Checkout would create a second subscription alongside a running trial. */
|
||||
const canSubscribe =
|
||||
!isEnterpriseCloud &&
|
||||
(data?.subscriptions?.length ?? 0) === 0 &&
|
||||
!billingStatus?.isOnTrial;
|
||||
const maxServers = admin?.user.serversQuantity ?? 1;
|
||||
const percentage = ((servers ?? 0) / maxServers) * 100;
|
||||
const safePercentage = Math.min(percentage, 100);
|
||||
@@ -322,126 +249,6 @@ export const ShowBilling = () => {
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full mt-6">
|
||||
{!isEnterpriseCloud &&
|
||||
billingStatus &&
|
||||
(billingStatus.plan || billingStatus.isOnTrial) && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border p-4 max-w-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
{billingStatus.isOnTrial ? (
|
||||
<Clock className="h-5 w-5 text-primary shrink-0" />
|
||||
) : (
|
||||
<CreditCard className="h-5 w-5 text-primary shrink-0" />
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{billingStatus.isOnTrial
|
||||
? "Free trial"
|
||||
: "Current plan"}
|
||||
</span>
|
||||
<Badge className="capitalize" variant="secondary">
|
||||
{billingStatus.plan ?? "Trial"}
|
||||
</Badge>
|
||||
{billingStatus.plan && (
|
||||
<Badge variant="outline">
|
||||
{billingStatus.isAnnual ? "Annual" : "Monthly"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{billingStatus.isOnTrial
|
||||
? `${billingStatus.trialDaysRemaining} day${billingStatus.trialDaysRemaining === 1 ? "" : "s"} left${
|
||||
billingStatus.trialEndsAt
|
||||
? ` · ends ${new Date(billingStatus.trialEndsAt).toLocaleDateString()}`
|
||||
: ""
|
||||
}${
|
||||
billingStatus.hasPaymentMethod
|
||||
? " · billing starts automatically"
|
||||
: " · add a card to keep your servers"
|
||||
}`
|
||||
: "You're subscribed and billed automatically."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{admin?.user.stripeCustomerId && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{billingStatus.isOnTrial &&
|
||||
useNewPricing &&
|
||||
billingStatus.plan &&
|
||||
billingStatus.plan !== "legacy" && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isLoading={switchingTrial}
|
||||
onClick={() =>
|
||||
handleSwitchTrialPlan(
|
||||
billingStatus.plan === "startup"
|
||||
? "hobby"
|
||||
: "startup",
|
||||
billingStatus.isAnnual,
|
||||
)
|
||||
}
|
||||
>
|
||||
{billingStatus.plan === "startup"
|
||||
? "Switch to Hobby"
|
||||
: "Switch to Startup"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isLoading={switchingTrial}
|
||||
onClick={() =>
|
||||
handleSwitchTrialPlan(
|
||||
billingStatus.plan as "hobby" | "startup",
|
||||
!billingStatus.isAnnual,
|
||||
billingStatus.isAnnual
|
||||
? "monthly billing"
|
||||
: "annual billing",
|
||||
)
|
||||
}
|
||||
>
|
||||
{billingStatus.isAnnual
|
||||
? "Switch to monthly"
|
||||
: "Switch to annual"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={
|
||||
billingStatus.isOnTrial &&
|
||||
!billingStatus.hasPaymentMethod
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
onClick={async () => {
|
||||
const session = await createCustomerPortalSession();
|
||||
window.open(session.url);
|
||||
}}
|
||||
>
|
||||
{billingStatus.isOnTrial &&
|
||||
!billingStatus.hasPaymentMethod
|
||||
? "Add payment method"
|
||||
: "Manage Subscription"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canStartTrial && (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
|
||||
<Clock className="h-5 w-5 text-primary shrink-0" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
7-day free trial on any plan
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No credit card required — pick Hobby or Startup below.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
|
||||
<div className="space-y-2 flex flex-col">
|
||||
<h3 className="text-lg font-medium">Servers Plan</h3>
|
||||
@@ -1002,7 +809,7 @@ export const ShowBilling = () => {
|
||||
"Unlimited Deployments",
|
||||
"Unlimited Databases",
|
||||
"Unlimited Applications",
|
||||
"Setup 1 Server",
|
||||
"1 Server Included",
|
||||
"1 Organization",
|
||||
"1 User",
|
||||
"2 Environments",
|
||||
@@ -1018,71 +825,68 @@ export const ShowBilling = () => {
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
{canSubscribe && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Servers:
|
||||
</span>
|
||||
<Button
|
||||
disabled={hobbyServerQuantity <= 1}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setHobbyServerQuantity((q) =>
|
||||
Math.max(1, q - 1),
|
||||
)
|
||||
}
|
||||
>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
value={hobbyServerQuantity}
|
||||
onChange={(e) =>
|
||||
setHobbyServerQuantity(
|
||||
Math.max(
|
||||
1,
|
||||
Number(
|
||||
(e.target as HTMLInputElement).value,
|
||||
) || 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setHobbyServerQuantity((q) => q + 1)
|
||||
}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Servers:
|
||||
</span>
|
||||
<Button
|
||||
disabled={hobbyServerQuantity <= 1}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setHobbyServerQuantity((q) => Math.max(1, q - 1))
|
||||
}
|
||||
>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
value={hobbyServerQuantity}
|
||||
onChange={(e) =>
|
||||
setHobbyServerQuantity(
|
||||
Math.max(
|
||||
1,
|
||||
Number(
|
||||
(e.target as HTMLInputElement).value,
|
||||
) || 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setHobbyServerQuantity((q) => q + 1)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{canStartTrial && (
|
||||
{admin?.user.stripeCustomerId && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
isLoading={trialTier === "hobby"}
|
||||
disabled={trialTier !== null}
|
||||
onClick={() => handleStartTrial("hobby")}
|
||||
onClick={async () => {
|
||||
const session =
|
||||
await createCustomerPortalSession();
|
||||
window.open(session.url);
|
||||
}}
|
||||
>
|
||||
Start 7-day free trial
|
||||
</Button>
|
||||
)}
|
||||
{canSubscribe && (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={canStartTrial ? "outline" : "default"}
|
||||
onClick={() =>
|
||||
handleCheckout("hobby", data!.hobbyProductId!)
|
||||
}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Get Started
|
||||
Manage Subscription
|
||||
</Button>
|
||||
)}
|
||||
{!isEnterpriseCloud &&
|
||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
handleCheckout("hobby", data!.hobbyProductId!)
|
||||
}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1137,7 +941,7 @@ export const ShowBilling = () => {
|
||||
All the features of Hobby, plus…
|
||||
</li>
|
||||
{[
|
||||
"Setup up to 3 Servers",
|
||||
"3 Servers Included",
|
||||
"3 Organizations",
|
||||
"Unlimited Users",
|
||||
"Unlimited Environments",
|
||||
@@ -1155,83 +959,85 @@ export const ShowBilling = () => {
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
{canSubscribe && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Servers (min. {STARTUP_SERVERS_INCLUDED} included)
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
disabled={
|
||||
startupServerQuantity <=
|
||||
STARTUP_SERVERS_INCLUDED
|
||||
}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() =>
|
||||
setStartupServerQuantity((q) =>
|
||||
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
|
||||
)
|
||||
}
|
||||
>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
value={startupServerQuantity}
|
||||
onChange={(e) =>
|
||||
setStartupServerQuantity(
|
||||
Math.max(
|
||||
STARTUP_SERVERS_INCLUDED,
|
||||
Number(
|
||||
(e.target as HTMLInputElement).value,
|
||||
) || STARTUP_SERVERS_INCLUDED,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="h-8 text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() =>
|
||||
setStartupServerQuantity((q) => q + 1)
|
||||
}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{canStartTrial && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Servers (min. {STARTUP_SERVERS_INCLUDED} included)
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="w-full"
|
||||
isLoading={trialTier === "startup"}
|
||||
disabled={trialTier !== null}
|
||||
onClick={() => handleStartTrial("startup")}
|
||||
>
|
||||
Start 7-day free trial
|
||||
</Button>
|
||||
)}
|
||||
{canSubscribe && (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={canStartTrial ? "outline" : "default"}
|
||||
disabled={
|
||||
startupServerQuantity <=
|
||||
STARTUP_SERVERS_INCLUDED
|
||||
}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() =>
|
||||
handleCheckout(
|
||||
"startup",
|
||||
data!.startupProductId!,
|
||||
setStartupServerQuantity((q) =>
|
||||
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
startupServerQuantity < STARTUP_SERVERS_INCLUDED
|
||||
>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
value={startupServerQuantity}
|
||||
onChange={(e) =>
|
||||
setStartupServerQuantity(
|
||||
Math.max(
|
||||
STARTUP_SERVERS_INCLUDED,
|
||||
Number(
|
||||
(e.target as HTMLInputElement).value,
|
||||
) || STARTUP_SERVERS_INCLUDED,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="h-8 text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() =>
|
||||
setStartupServerQuantity((q) => q + 1)
|
||||
}
|
||||
>
|
||||
Get Started
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{admin?.user.stripeCustomerId && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
const session =
|
||||
await createCustomerPortalSession();
|
||||
window.open(session.url);
|
||||
}}
|
||||
>
|
||||
Manage Subscription
|
||||
</Button>
|
||||
)}
|
||||
{!isEnterpriseCloud &&
|
||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
handleCheckout(
|
||||
"startup",
|
||||
data!.startupProductId!,
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
startupServerQuantity <
|
||||
STARTUP_SERVERS_INCLUDED
|
||||
}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1423,17 +1229,31 @@ export const ShowBilling = () => {
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mt-4 w-full">
|
||||
{canSubscribe && (
|
||||
{admin?.user.stripeCustomerId && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
handleCheckout("legacy", product.id);
|
||||
const session =
|
||||
await createCustomerPortalSession();
|
||||
window.open(session.url);
|
||||
}}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Subscribe
|
||||
Manage Subscription
|
||||
</Button>
|
||||
)}
|
||||
{!isEnterpriseCloud &&
|
||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
handleCheckout("legacy", product.id);
|
||||
}}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ShowBilling } from "@/components/dashboard/settings/billing/show-billing";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const ShowWelcomeDokploy = () => {
|
||||
const { data } = api.user.get.useQuery();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data: isCloud, isPending } = api.settings.isCloud.useQuery();
|
||||
|
||||
if (!isCloud || data?.role !== "admin") {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isPending &&
|
||||
isCloud &&
|
||||
!localStorage.getItem("hasSeenCloudWelcomeModal") &&
|
||||
data?.role === "owner"
|
||||
) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [isCloud, isPending]);
|
||||
|
||||
const handleClose = (isOpen: boolean) => {
|
||||
if (data?.role === "owner") {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
localStorage.setItem("hasSeenCloudWelcomeModal", "true"); // Establece el flag al cerrar el modal
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-semibold text-center">
|
||||
Welcome to Dokploy Cloud 🎉
|
||||
</DialogTitle>
|
||||
<p className="text-center text-sm text-muted-foreground mt-2">
|
||||
Unlock powerful features to streamline your deployments and manage
|
||||
projects effortlessly.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 space-y-3 text-sm text-primary ">
|
||||
<ShowBilling />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,421 +0,0 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Cloud, CloudOff, XIcon } from "lucide-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,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const DNS_RECORD_TYPES = [
|
||||
"A",
|
||||
"AAAA",
|
||||
"CNAME",
|
||||
"MX",
|
||||
"TXT",
|
||||
"NS",
|
||||
"SRV",
|
||||
"CAA",
|
||||
"PTR",
|
||||
] as const;
|
||||
|
||||
type RecordType = (typeof DNS_RECORD_TYPES)[number];
|
||||
|
||||
export const PROXIABLE_TYPES: readonly string[] = ["A", "AAAA", "CNAME"];
|
||||
|
||||
const valueFields: Record<
|
||||
RecordType,
|
||||
{ label: string; placeholder: string; hint?: string }
|
||||
> = {
|
||||
A: { label: "IPv4 address", placeholder: "203.0.113.10" },
|
||||
AAAA: { label: "IPv6 address", placeholder: "2001:db8::1" },
|
||||
CNAME: { label: "Target", placeholder: "app.example.com" },
|
||||
MX: {
|
||||
label: "Mail server",
|
||||
placeholder: "10 mail.example.com",
|
||||
hint: "Start with the priority, then the mail server.",
|
||||
},
|
||||
TXT: { label: "Value", placeholder: "v=spf1 include:_spf.example.com ~all" },
|
||||
NS: { label: "Nameserver", placeholder: "ns1.example.com" },
|
||||
SRV: {
|
||||
label: "Target",
|
||||
placeholder: "1 10 5269 talk.example.com",
|
||||
hint: "Priority, weight, port, then target.",
|
||||
},
|
||||
CAA: {
|
||||
label: "Value",
|
||||
placeholder: '0 issue "letsencrypt.org"',
|
||||
hint: "Flags, tag, then the quoted value.",
|
||||
},
|
||||
PTR: { label: "Target", placeholder: "host.example.com" },
|
||||
};
|
||||
|
||||
const structuredValuePatterns: Partial<Record<RecordType, RegExp>> = {
|
||||
SRV: /^\d+\s+\d+\s+\d+\s+\S+$/,
|
||||
CAA: /^\d+\s+\S+\s+.+$/,
|
||||
};
|
||||
|
||||
const DnsRecordSchema = z
|
||||
.object({
|
||||
type: z.enum(DNS_RECORD_TYPES),
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
content: z.string().min(1, { message: "Content is required" }),
|
||||
ttl: z.string(),
|
||||
proxied: z.boolean(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const values = data.content
|
||||
.split("\n")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
if (!values.length) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["content"],
|
||||
message: "Content is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const pattern = structuredValuePatterns[data.type];
|
||||
if (pattern && !values.every((value) => pattern.test(value))) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["content"],
|
||||
message: `Expected ${valueFields[data.type].placeholder}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type DnsRecordForm = z.infer<typeof DnsRecordSchema>;
|
||||
|
||||
export interface DnsRecordValue {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl: number;
|
||||
proxied?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
dnsProviderId: string;
|
||||
zoneId: string;
|
||||
zoneName: string;
|
||||
record: DnsRecordValue | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DnsRecordPanel = ({
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
zoneName,
|
||||
record,
|
||||
onClose,
|
||||
}: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const createRecord = api.dnsProvider.createRecord.useMutation();
|
||||
const updateRecord = api.dnsProvider.updateRecord.useMutation();
|
||||
const { mutateAsync, isPending, error, isError } = record
|
||||
? updateRecord
|
||||
: createRecord;
|
||||
|
||||
const { data: provider } = api.dnsProvider.one.useQuery({ dnsProviderId });
|
||||
const { data: servers } = api.server.all.useQuery();
|
||||
const { data: panelPublicIp } = api.server.publicIp.useQuery();
|
||||
const { data: panelStoredIp } = api.settings.getIp.useQuery();
|
||||
|
||||
const panelIp = panelPublicIp || panelStoredIp;
|
||||
const ipSuggestions = [
|
||||
...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []),
|
||||
...(servers ?? []).map((server) => ({
|
||||
ip: server.ipAddress,
|
||||
label: server.name,
|
||||
})),
|
||||
].filter(
|
||||
(suggestion, index, all) =>
|
||||
!!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index,
|
||||
);
|
||||
|
||||
const form = useForm<DnsRecordForm>({
|
||||
defaultValues: record
|
||||
? {
|
||||
type: (DNS_RECORD_TYPES.includes(record.type as RecordType)
|
||||
? record.type
|
||||
: "A") as RecordType,
|
||||
name: record.name,
|
||||
content: record.content,
|
||||
ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "",
|
||||
proxied: record.proxied ?? false,
|
||||
}
|
||||
: { type: "A", name: "", content: "", ttl: "", proxied: false },
|
||||
resolver: zodResolver(DnsRecordSchema),
|
||||
});
|
||||
|
||||
const type = form.watch("type");
|
||||
const proxied = form.watch("proxied");
|
||||
const canProxy =
|
||||
provider?.providerType === "cloudflare" && PROXIABLE_TYPES.includes(type);
|
||||
const supportsMultipleValues = provider?.providerType === "route53";
|
||||
const usesAutomaticTtl = canProxy && proxied;
|
||||
|
||||
const onSubmit = async (data: DnsRecordForm) => {
|
||||
const name = data.name.trim() === "@" ? zoneName : data.name;
|
||||
const isProxied = canProxy && data.proxied;
|
||||
const payload = {
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
type: data.type,
|
||||
name,
|
||||
content: data.content,
|
||||
ttl: !isProxied && data.ttl ? Number(data.ttl) : undefined,
|
||||
...(canProxy && { proxied: data.proxied }),
|
||||
...(record && { recordId: record.id }),
|
||||
};
|
||||
await mutateAsync(payload as any)
|
||||
.then(() => {
|
||||
toast.success(record ? "Record updated" : "Record created");
|
||||
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
|
||||
onClose();
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg border bg-muted/30 p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{record ? "Edit record" : "New record"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{zoneName}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{DNS_RECORD_TYPES.map((recordType) => (
|
||||
<SelectItem key={recordType} value={recordType}>
|
||||
{recordType}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="app.example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use <code>@</code> for the root domain.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{type === "A" && ipSuggestions.length > 0 && (
|
||||
<FormItem>
|
||||
<FormLabel>Fill from server (optional)</FormLabel>
|
||||
<Select
|
||||
onValueChange={(ip) =>
|
||||
form.setValue("content", ip, { shouldValidate: true })
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{ipSuggestions.map((suggestion) => (
|
||||
<SelectItem key={suggestion.ip} value={suggestion.ip}>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span>{suggestion.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{suggestion.ip}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{valueFields[type].label}</FormLabel>
|
||||
<FormControl>
|
||||
{supportsMultipleValues ? (
|
||||
<Textarea
|
||||
className="min-h-[60px] font-mono text-xs"
|
||||
placeholder={valueFields[type].placeholder}
|
||||
{...field}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={valueFields[type].placeholder}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
{(supportsMultipleValues || valueFields[type].hint) && (
|
||||
<FormDescription>
|
||||
{[
|
||||
valueFields[type].hint,
|
||||
supportsMultipleValues &&
|
||||
"One value per line: every line belongs to the same record set.",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{canProxy && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="proxied"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Proxy status</FormLabel>
|
||||
<fieldset className="relative grid min-w-0 grid-cols-2 rounded-lg border bg-muted/40 p-1">
|
||||
<legend className="sr-only">Proxy status</legend>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-y-1 left-1 z-0 w-[calc(50%-4px)] rounded-md bg-background shadow-sm ring-1 ring-foreground/10 transition-transform duration-[250ms] ease-[var(--ease-smooth-out)] will-change-transform motion-reduce:transition-none",
|
||||
field.value && "translate-x-full",
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={!field.value}
|
||||
onClick={() => field.onChange(false)}
|
||||
className={cn(
|
||||
"relative z-10 flex h-8 items-center justify-center gap-1.5 rounded-md text-xs font-medium transition-colors duration-[250ms] ease-[var(--ease-smooth-out)] outline-none focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none",
|
||||
field.value
|
||||
? "text-muted-foreground hover:text-foreground"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
<CloudOff className="size-3.5" />
|
||||
DNS only
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={field.value}
|
||||
onClick={() => field.onChange(true)}
|
||||
className={cn(
|
||||
"relative z-10 flex h-8 items-center justify-center gap-1.5 rounded-md text-xs font-medium transition-colors duration-[250ms] ease-[var(--ease-smooth-out)] outline-none focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none",
|
||||
field.value
|
||||
? "text-[#f6821f]"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Cloud className="size-3.5" />
|
||||
Proxied
|
||||
</button>
|
||||
</fieldset>
|
||||
<FormDescription>
|
||||
{field.value
|
||||
? "Traffic runs through Cloudflare and the origin IP stays hidden."
|
||||
: "Cloudflare only answers the DNS query; traffic reaches the origin directly."}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ttl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>TTL (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
className="tabular-nums"
|
||||
disabled={usesAutomaticTtl}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
{usesAutomaticTtl && (
|
||||
<FormDescription>
|
||||
Proxied records always use automatic TTL.
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{record ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user