mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-19 07:01:35 +00:00
Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0c8d80cde | |||
| 49d250aaa9 | |||
| bef77ac84f | |||
| a67d9e3522 | |||
| e38a980d9f | |||
| 829f8af63f | |||
| 41cecde735 | |||
| 3e75dc64f3 | |||
| 7430afd701 | |||
| 837730038c | |||
| 53464e9096 | |||
| 136a09d016 | |||
| c867a3c4c9 | |||
| 471e91caeb | |||
| 16c22c74fd | |||
| 37df4e484e | |||
| 5ff8d05ff7 | |||
| a9871f3a8d | |||
| 0049421095 | |||
| 3d67665c33 | |||
| 2bfcac58b0 | |||
| a7d8378f52 | |||
| bdefc02e77 | |||
| 293e19703a | |||
| cd9edb290b | |||
| b5473e63fd | |||
| e0006758c0 | |||
| d9fe046396 | |||
| bfe24f89f9 | |||
| 502d6c8a18 | |||
| 9e87b9e401 | |||
| a7d8dff360 | |||
| 94f3815eb4 | |||
| 931f5c1335 | |||
| aae2dd9377 | |||
| fe3912dc4b | |||
| ccd26e946b | |||
| 29107d88fc | |||
| c7ee0430f7 | |||
| 3cc8158fdc | |||
| 726664ab3c | |||
| 20701ba272 | |||
| 412516d47b | |||
| b4681b318f | |||
| 0898642e32 | |||
| d2779136c5 | |||
| 0b537a815d | |||
| b329595370 | |||
| 2674eb200d | |||
| e8f236c9db | |||
| 749bb2c1ce | |||
| 9992659f24 | |||
| 88cb057e06 | |||
| 7289a3f68e | |||
| 949ac8e0a2 | |||
| 5be17d3dd0 | |||
| 5ccc15f00d | |||
| 5aa28bc7d3 | |||
| 20575da9fe | |||
| 06b3bb282a | |||
| 5c19d1b704 | |||
| 2bc3741e41 | |||
| 35d26a806b | |||
| a4a13b90b8 | |||
| 48504fde4e | |||
| 84a1d7093c | |||
| 90d522c846 | |||
| c6a2b2437d | |||
| cec5b65cd6 | |||
| 52f61adfe8 | |||
| 169bac2ef8 | |||
| 916bb50e73 | |||
| 0d3a3966b0 | |||
| 560e790e73 | |||
| ff2e454dca | |||
| 82545bf500 | |||
| 302754bd3b | |||
| 6e7934fc55 | |||
| c18609a127 | |||
| 618a7df25c | |||
| 1c6d333739 | |||
| 72f2c4d5d1 | |||
| e8be2693e0 | |||
| 4396451095 | |||
| 7dc066a7bd | |||
| a0b6f233cb | |||
| d0ec74fbb8 | |||
| 382fdfc478 | |||
| c231c2979b | |||
| 3951247ebd | |||
| 7b89d1f948 | |||
| 6215c7f159 | |||
| 52ea2ce071 | |||
| a0f1a5119c | |||
| 5e0b021ec2 | |||
| a0e0574bcd | |||
| c453ac3a2f | |||
| 143dfcafe3 | |||
| 3563f585f9 | |||
| f2ba262499 | |||
| f397c1a862 | |||
| d7d75f7c5c | |||
| a0e1001808 | |||
| 8236c31fcd | |||
| 037230496c | |||
| 14e059784c | |||
| f34151bc12 | |||
| d00ae31920 | |||
| 95b2712c26 | |||
| 318ad36967 | |||
| a87a5c06f0 | |||
| e120340e77 | |||
| 0d6e984b54 | |||
| 1e9873b3e4 | |||
| 26317e9126 | |||
| 853ca33659 | |||
| a90c2a662f | |||
| 45dcdfc5be | |||
| 29cdc815db | |||
| 9fa98de9c1 | |||
| a4a1d15656 | |||
| 326908869d | |||
| 18d07c7254 | |||
| 4cd4b83523 | |||
| f91b2e2ca6 | |||
| e70c880612 | |||
| 0c1dfe978c | |||
| 88118cdb3f | |||
| 38d157adc0 | |||
| 3b1fec5409 | |||
| 49b8214e5d | |||
| ec31739e0e | |||
| d5328703fa | |||
| f4cb26cbf4 | |||
| a6c1f3048f | |||
| 9cae609a0a | |||
| 2c234d6e20 | |||
| 9dcdb53aa1 | |||
| bda8124291 | |||
| 96778433db | |||
| f25810fa72 | |||
| 341f38bba6 | |||
| 19589694ea | |||
| 1f1acacf58 | |||
| f5c21db1ef |
@@ -14,34 +14,34 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Get version from package.json
|
- name: Get version from package.json
|
||||||
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> $GITHUB_ENV
|
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Get latest GitHub tag
|
- name: Get latest GitHub tag
|
||||||
run: |
|
run: |
|
||||||
LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1)
|
LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1)
|
||||||
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV
|
echo "LATEST_TAG=$LATEST_TAG" >> "$GITHUB_ENV"
|
||||||
echo $LATEST_TAG
|
echo "$LATEST_TAG"
|
||||||
- name: Compare versions
|
- name: Compare versions
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then
|
if [ "$VERSION" != "$LATEST_TAG" ]; then
|
||||||
VERSION_CHANGED="true"
|
VERSION_CHANGED="true"
|
||||||
else
|
else
|
||||||
VERSION_CHANGED="false"
|
VERSION_CHANGED="false"
|
||||||
fi
|
fi
|
||||||
echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV
|
echo "VERSION_CHANGED=$VERSION_CHANGED" >> "$GITHUB_ENV"
|
||||||
echo "Comparing versions:"
|
echo "Comparing versions:"
|
||||||
echo "Current version: ${{ env.VERSION }}"
|
echo "Current version: $VERSION"
|
||||||
echo "Latest tag: ${{ env.LATEST_TAG }}"
|
echo "Latest tag: $LATEST_TAG"
|
||||||
echo "Version changed: $VERSION_CHANGED"
|
echo "Version changed: $VERSION_CHANGED"
|
||||||
- name: Check if a PR already exists
|
- name: Check if a PR already exists
|
||||||
run: |
|
run: |
|
||||||
PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length')
|
PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length')
|
||||||
echo "PR_EXISTS=$PR_EXISTS" >> $GITHUB_ENV
|
echo "PR_EXISTS=$PR_EXISTS" >> "$GITHUB_ENV"
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GH_PAT }}
|
GH_TOKEN: ${{ secrets.GH_PAT }}
|
||||||
|
|
||||||
@@ -56,12 +56,12 @@ jobs:
|
|||||||
git push origin canary
|
git push origin canary
|
||||||
|
|
||||||
gh pr create \
|
gh pr create \
|
||||||
--title "🚀 Release ${{ env.VERSION }}" \
|
--title "🚀 Release $VERSION" \
|
||||||
--body '
|
--body "
|
||||||
This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}.
|
This PR promotes changes from \`canary\` to \`main\` for version $VERSION.
|
||||||
|
|
||||||
### 🔍 Changes Include:
|
### 🔍 Changes Include:
|
||||||
- Version bump to ${{ env.VERSION }}
|
- Version bump to $VERSION
|
||||||
- All changes from canary branch
|
- All changes from canary branch
|
||||||
|
|
||||||
### ✅ Pre-merge Checklist:
|
### ✅ Pre-merge Checklist:
|
||||||
@@ -69,7 +69,7 @@ jobs:
|
|||||||
- [ ] Documentation updated
|
- [ ] Documentation updated
|
||||||
- [ ] Docker images built and tested
|
- [ ] Docker images built and tested
|
||||||
|
|
||||||
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)' \
|
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)" \
|
||||||
--base main \
|
--base main \
|
||||||
--head canary \
|
--head canary \
|
||||||
--label "release" --label "automated pr" || true \
|
--label "release" --label "automated pr" || true \
|
||||||
|
|||||||
@@ -5,33 +5,38 @@ on:
|
|||||||
branches: [main, canary]
|
branches: [main, canary]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push-cloud-image:
|
build-and-push-cloud-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set tag and version
|
- name: Set tag and version
|
||||||
id: meta-cloud
|
id: meta-cloud
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||||
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT
|
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT
|
echo "tags=siumauricio/cloud:canary" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.cloud
|
file: ./Dockerfile.cloud
|
||||||
@@ -48,26 +53,28 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set tag and version
|
- name: Set tag and version
|
||||||
id: meta-schedule
|
id: meta-schedule
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||||
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT
|
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT
|
echo "tags=siumauricio/schedule:canary" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.schedule
|
file: ./Dockerfile.schedule
|
||||||
@@ -80,26 +87,28 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set tag and version
|
- name: Set tag and version
|
||||||
id: meta-server
|
id: meta-server
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||||
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT
|
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT
|
echo "tags=siumauricio/server:canary" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.server
|
file: ./Dockerfile.server
|
||||||
|
|||||||
@@ -8,18 +8,23 @@ on:
|
|||||||
env:
|
env:
|
||||||
IMAGE_NAME: dokploy/dokploy
|
IMAGE_NAME: dokploy/dokploy
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-amd:
|
docker-amd:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -27,15 +32,15 @@ jobs:
|
|||||||
- name: Set tag and version
|
- name: Set tag and version
|
||||||
id: meta
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||||
TAG="canary"
|
TAG="canary"
|
||||||
else
|
else
|
||||||
TAG="feature"
|
TAG="feature"
|
||||||
fi
|
fi
|
||||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
|
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Prepare env file
|
- name: Prepare env file
|
||||||
run: |
|
run: |
|
||||||
@@ -43,7 +48,7 @@ jobs:
|
|||||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
@@ -53,13 +58,15 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -68,15 +75,15 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||||
TAG="canary"
|
TAG="canary"
|
||||||
else
|
else
|
||||||
TAG="feature"
|
TAG="feature"
|
||||||
fi
|
fi
|
||||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
|
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Prepare env file
|
- name: Prepare env file
|
||||||
run: |
|
run: |
|
||||||
@@ -84,7 +91,7 @@ jobs:
|
|||||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/arm64
|
platforms: linux/arm64
|
||||||
@@ -96,20 +103,22 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Create and push manifests
|
- name: Create and push manifests
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
|
|
||||||
@@ -121,7 +130,7 @@ jobs:
|
|||||||
${IMAGE_NAME}:${TAG}-amd64 \
|
${IMAGE_NAME}:${TAG}-amd64 \
|
||||||
${IMAGE_NAME}:${TAG}-arm64
|
${IMAGE_NAME}:${TAG}-arm64
|
||||||
|
|
||||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||||
TAG="canary"
|
TAG="canary"
|
||||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||||
${IMAGE_NAME}:${TAG}-amd64 \
|
${IMAGE_NAME}:${TAG}-amd64 \
|
||||||
@@ -138,32 +147,37 @@ jobs:
|
|||||||
needs: [combine-manifests]
|
needs: [combine-manifests]
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.get_version.outputs.version }}
|
version: ${{ steps.get_version.outputs.version }}
|
||||||
npm_version: ${{ steps.get_version.outputs.npm_version }}
|
npm_version: ${{ steps.get_version.outputs.npm_version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Get version
|
- name: Get version
|
||||||
id: get_version
|
id: get_version
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT
|
echo "npm_version=${VERSION#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Fetch install.sh
|
- name: Fetch install.sh
|
||||||
|
env:
|
||||||
|
RELEASE_VERSION: ${{ steps.get_version.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
|
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
|
||||||
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
|
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
|
||||||
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
|
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
|
||||||
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
|
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${RELEASE_VERSION}}\""; tail -n +2 install.sh; } > install-pinned.sh
|
||||||
mv install-pinned.sh install.sh
|
mv install-pinned.sh install.sh
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.get_version.outputs.version }}
|
tag_name: ${{ steps.get_version.outputs.version }}
|
||||||
target_commitish: ${{ github.sha }}
|
target_commitish: ${{ github.sha }}
|
||||||
@@ -179,15 +193,19 @@ jobs:
|
|||||||
needs: [generate-release]
|
needs: [generate-release]
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
NPM_VERSION: ${{ needs.generate-release.outputs.npm_version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
version: 10.22.0
|
version: 10.22.0
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24.4.0
|
node-version: 24.4.0
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
@@ -202,63 +220,63 @@ jobs:
|
|||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
|
||||||
cd /tmp/mcp-repo
|
cd /tmp/mcp-repo
|
||||||
|
|
||||||
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
||||||
mv package.json.tmp package.json
|
mv package.json.tmp package.json
|
||||||
|
|
||||||
cp ${{ github.workspace }}/openapi.json src/generated/openapi.json
|
cp $GITHUB_WORKSPACE/openapi.json src/generated/openapi.json
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run generate
|
pnpm run generate
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
git config user.name "Dokploy Bot"
|
||||||
git config user.email "bot@dokploy.com"
|
git config user.email "bot@dokploy.com"
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||||
--allow-empty
|
--allow-empty
|
||||||
git push
|
git push
|
||||||
|
|
||||||
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
echo "✅ MCP repo synced to version $NPM_VERSION"
|
||||||
|
|
||||||
- name: Sync version to CLI repository
|
- name: Sync version to CLI repository
|
||||||
run: |
|
run: |
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
|
||||||
cd /tmp/cli-repo
|
cd /tmp/cli-repo
|
||||||
|
|
||||||
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
||||||
mv package.json.tmp package.json
|
mv package.json.tmp package.json
|
||||||
|
|
||||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run generate
|
pnpm run generate
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
git config user.name "Dokploy Bot"
|
||||||
git config user.email "bot@dokploy.com"
|
git config user.email "bot@dokploy.com"
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||||
--allow-empty
|
--allow-empty
|
||||||
git push
|
git push
|
||||||
|
|
||||||
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
echo "✅ CLI repo synced to version $NPM_VERSION"
|
||||||
|
|
||||||
- name: Sync version to SDK repository
|
- name: Sync version to SDK repository
|
||||||
run: |
|
run: |
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
|
||||||
cd /tmp/sdk-repo
|
cd /tmp/sdk-repo
|
||||||
|
|
||||||
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
||||||
mv package.json.tmp package.json
|
mv package.json.tmp package.json
|
||||||
|
|
||||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run generate
|
pnpm run generate
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
git config user.name "Dokploy Bot"
|
||||||
git config user.email "bot@dokploy.com"
|
git config user.email "bot@dokploy.com"
|
||||||
git add -A
|
git add -A
|
||||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||||
--allow-empty
|
--allow-empty
|
||||||
git push
|
git push
|
||||||
|
|
||||||
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
echo "✅ SDK repo synced to version $NPM_VERSION"
|
||||||
|
|||||||
@@ -6,15 +6,20 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [canary]
|
branches: [canary]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
format:
|
format:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup biomeJs
|
- name: Setup biomeJs
|
||||||
uses: biomejs/setup-biome@v2
|
uses: biomejs/setup-biome@v2
|
||||||
|
|
||||||
- name: Run Biome formatter
|
- name: Run Biome formatter
|
||||||
run: biome format --write
|
run: biome format --write
|
||||||
|
|||||||
@@ -16,17 +16,19 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout main
|
- name: Checkout main
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
||||||
|
|
||||||
- name: Cherry-pick fix to main
|
- name: Cherry-pick fix to main
|
||||||
|
env:
|
||||||
|
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
|
||||||
run: |
|
run: |
|
||||||
git config user.name "Dokploy Bot"
|
git config user.name "Dokploy Bot"
|
||||||
git config user.email "bot@dokploy.com"
|
git config user.email "bot@dokploy.com"
|
||||||
SHA="${{ github.event.pull_request.merge_commit_sha }}"
|
SHA="$MERGE_SHA"
|
||||||
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
|
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
|
||||||
git cherry-pick -x -m 1 "$SHA"
|
git cherry-pick -x -m 1 "$SHA"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ concurrency:
|
|||||||
group: hotfix-to-main
|
group: hotfix-to-main
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
# Push to main goes through HOTFIX_PUSH_TOKEN, not the workflow token.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout main
|
- name: Checkout main
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
||||||
|
|||||||
@@ -7,18 +7,23 @@ on:
|
|||||||
env:
|
env:
|
||||||
IMAGE_NAME: dokploy/monitoring
|
IMAGE_NAME: dokploy/monitoring
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-amd:
|
docker-amd:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -26,17 +31,17 @@ jobs:
|
|||||||
- name: Set tag
|
- name: Set tag
|
||||||
id: meta
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||||
TAG="canary"
|
TAG="canary"
|
||||||
else
|
else
|
||||||
TAG="feature"
|
TAG="feature"
|
||||||
fi
|
fi
|
||||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
|
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.monitoring
|
file: ./Dockerfile.monitoring
|
||||||
@@ -47,13 +52,15 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04-arm
|
runs-on: ubuntu-24.04-arm
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -61,17 +68,17 @@ jobs:
|
|||||||
- name: Set
|
- name: Set
|
||||||
id: meta
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||||
TAG="canary"
|
TAG="canary"
|
||||||
else
|
else
|
||||||
TAG="feature"
|
TAG="feature"
|
||||||
fi
|
fi
|
||||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
|
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.monitoring
|
file: ./Dockerfile.monitoring
|
||||||
@@ -84,27 +91,29 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Create and push manifests
|
- name: Create and push manifests
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||||
TAG="latest"
|
TAG="latest"
|
||||||
|
|
||||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||||
${IMAGE_NAME}:${TAG}-amd64 \
|
${IMAGE_NAME}:${TAG}-amd64 \
|
||||||
${IMAGE_NAME}:${TAG}-arm64
|
${IMAGE_NAME}:${TAG}-arm64
|
||||||
|
|
||||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||||
TAG="canary"
|
TAG="canary"
|
||||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||||
${IMAGE_NAME}:${TAG}-amd64 \
|
${IMAGE_NAME}:${TAG}-amd64 \
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
job: [build, test, typecheck]
|
job: [build, test, typecheck]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
- uses: pnpm/action-setup@v5
|
with:
|
||||||
- uses: actions/setup-node@v5
|
persist-credentials: false
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24.4.0
|
node-version: 24.4.0
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|||||||
@@ -12,17 +12,20 @@ on:
|
|||||||
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
generate-and-commit:
|
generate-and-commit:
|
||||||
name: Generate OpenAPI and commit to Dokploy repo
|
name: Generate OpenAPI and commit to Dokploy repo
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Dokploy repository
|
- name: Checkout Dokploy repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
persist-credentials: false
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v6
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
node-version: 24.4.0
|
node-version: 24.4.0
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ env:
|
|||||||
DOKPLOY_SERVICE: dokploy
|
DOKPLOY_SERVICE: dokploy
|
||||||
DOKPLOY_PORT: 3000
|
DOKPLOY_PORT: 3000
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
@@ -421,8 +424,10 @@ jobs:
|
|||||||
# ── Job summary ────────────────────────────────────────────────────────
|
# ── Job summary ────────────────────────────────────────────────────────
|
||||||
- name: Write job summary
|
- name: Write job summary
|
||||||
if: always()
|
if: always()
|
||||||
|
env:
|
||||||
|
JOB_STATUS: ${{ job.status }}
|
||||||
run: |
|
run: |
|
||||||
STATUS="${{ job.status }}"
|
STATUS="$JOB_STATUS"
|
||||||
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
|
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
|
||||||
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
|
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
|
||||||
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
|
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ Dokploy includes multiple features to make your life easier.
|
|||||||
- **Templates**: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click.
|
- **Templates**: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click.
|
||||||
- **Traefik Integration**: Automatically integrates with Traefik for routing and load balancing.
|
- **Traefik Integration**: Automatically integrates with Traefik for routing and load balancing.
|
||||||
- **Real-time Monitoring**: Monitor CPU, memory, storage, and network usage for every resource.
|
- **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.
|
- **Docker Management**: Easily deploy and manage Docker containers.
|
||||||
- **CLI/API**: Manage your applications and databases using the command line or through the API.
|
- **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.).
|
- **Notifications**: Get notified when your deployments succeed or fail (via Slack, Discord, Telegram, Email, etc.).
|
||||||
|
|||||||
@@ -10,12 +10,12 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"inngest": "3.40.1",
|
"inngest": "3.54.0",
|
||||||
"@dokploy/server": "workspace:*",
|
"@dokploy/server": "workspace:*",
|
||||||
"@hono/node-server": "^1.14.3",
|
"@hono/node-server": "^1.19.15",
|
||||||
"@hono/zod-validator": "0.7.6",
|
"@hono/zod-validator": "0.7.6",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"hono": "^4.11.7",
|
"hono": "^4.13.5",
|
||||||
"pino": "9.4.0",
|
"pino": "9.4.0",
|
||||||
"pino-pretty": "11.2.2",
|
"pino-pretty": "11.2.2",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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,4 +1,6 @@
|
|||||||
|
import type { Destination } from "@dokploy/server/services/destination";
|
||||||
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
|
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
|
||||||
|
import { getS3Credentials } from "@dokploy/server/utils/backups/utils";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
describe("redactRcloneCredentials (#4621)", () => {
|
describe("redactRcloneCredentials (#4621)", () => {
|
||||||
@@ -48,3 +50,48 @@ describe("redactRcloneCredentials (#4621)", () => {
|
|||||||
expect(redacted).toContain("[REDACTED]");
|
expect(redacted).toContain("[REDACTED]");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("redactRcloneCredentials with getS3Credentials output (#5519)", () => {
|
||||||
|
const buildCommand = (accessKey: string, secretAccessKey: string) =>
|
||||||
|
`rclone rcat ${getS3Credentials({
|
||||||
|
accessKey,
|
||||||
|
secretAccessKey,
|
||||||
|
region: "us-west-001",
|
||||||
|
endpoint: "https://s3.us-west-001.backblazeb2.com",
|
||||||
|
provider: "Other",
|
||||||
|
} as Destination).join(" ")} :s3:bucket/file.gz`;
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"plain alphanumeric",
|
||||||
|
"001aaaabbbbccccdd0000000001",
|
||||||
|
"K001FAKEfakeFAKEfake",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"containing slashes",
|
||||||
|
"AKIAIOSFODNN7EXAMPLE",
|
||||||
|
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||||
|
],
|
||||||
|
["containing spaces", "key with space", "secret with space"],
|
||||||
|
["containing quotes", "it's-a-key", `say "hi" it's`],
|
||||||
|
["containing shell metacharacters", "key$HOME", "sec;ret`id`&|\\x"],
|
||||||
|
])("redacts credentials %s", (_, accessKey, secretAccessKey) => {
|
||||||
|
const redacted = redactRcloneCredentials(
|
||||||
|
buildCommand(accessKey, secretAccessKey),
|
||||||
|
);
|
||||||
|
expect(redacted).toContain(
|
||||||
|
'--s3-access-key-id="[REDACTED]" --s3-secret-access-key="[REDACTED]" --s3-region=us-west-001',
|
||||||
|
);
|
||||||
|
expect(redacted).not.toContain(accessKey);
|
||||||
|
expect(redacted).not.toContain(secretAccessKey);
|
||||||
|
expect(redacted).toContain(":s3:bucket/file.gz");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts credentials embedded in an error string", () => {
|
||||||
|
const errorStr = `Error: Command failed: ${buildCommand("MYKEY", "MY/SECRET")}`;
|
||||||
|
const redacted = redactRcloneCredentials(errorStr);
|
||||||
|
expect(redacted).not.toContain("MYKEY");
|
||||||
|
expect(redacted).not.toContain("MY/SECRET");
|
||||||
|
expect(redacted).toContain("[REDACTED]");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { createCommand } from "@dokploy/server/utils/builders/compose";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
composeType: "docker-compose" as const,
|
||||||
|
appName: "compose-app",
|
||||||
|
sourceType: "github" as const,
|
||||||
|
composePath: "./docker-compose.yml",
|
||||||
|
command: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("compose createCommand --pull always", () => {
|
||||||
|
it("adds --pull always when pullImages is enabled", () => {
|
||||||
|
const cmd = createCommand({ ...base, pullImages: true } as any);
|
||||||
|
|
||||||
|
expect(cmd).toContain("up -d --build --remove-orphans --pull always");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits --pull when pullImages is disabled", () => {
|
||||||
|
const cmd = createCommand({ ...base, pullImages: false } as any);
|
||||||
|
|
||||||
|
expect(cmd).not.toContain("--pull");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add --pull to stack deploy", () => {
|
||||||
|
const cmd = createCommand({
|
||||||
|
...base,
|
||||||
|
composeType: "stack",
|
||||||
|
pullImages: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(cmd).not.toContain("--pull");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a custom command untouched", () => {
|
||||||
|
const cmd = createCommand({
|
||||||
|
...base,
|
||||||
|
command: "compose -p compose-app up -d",
|
||||||
|
pullImages: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(cmd).toBe("compose -p compose-app up -d");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,8 +33,16 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
test-data:
|
test-data:
|
||||||
`,
|
`,
|
||||||
|
composeId: "compose-1",
|
||||||
|
name: "raw-stack",
|
||||||
composePath: "./docker-compose.yml",
|
composePath: "./docker-compose.yml",
|
||||||
composeType: "stack",
|
composeType: "stack",
|
||||||
|
environment: {
|
||||||
|
environmentId: "env-1",
|
||||||
|
name: "production",
|
||||||
|
projectId: "project-1",
|
||||||
|
project: { name: "shop", organizationId: "org-1" },
|
||||||
|
},
|
||||||
isolatedDeployment: false,
|
isolatedDeployment: false,
|
||||||
isolatedDeploymentsVolume: false,
|
isolatedDeploymentsVolume: false,
|
||||||
randomize: false,
|
randomize: false,
|
||||||
@@ -54,6 +62,72 @@ volumes:
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(converted?.services?.test?.tmpfs).toEqual(["/cache"]);
|
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();
|
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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { Compose, ComposeSpecification } from "@dokploy/server";
|
||||||
|
import {
|
||||||
|
addAppNameToPreventCollision,
|
||||||
|
addSuffixToAllProperties,
|
||||||
|
} from "@dokploy/server";
|
||||||
|
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
|
||||||
|
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||||
|
import { expect, test, vi } from "vitest";
|
||||||
|
import { stringify } from "yaml";
|
||||||
|
import composeSpec from "../../../components/shared/compose-spec.json";
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<
|
||||||
|
typeof import("@dokploy/server/utils/process/execAsync")
|
||||||
|
>()),
|
||||||
|
execAsyncRemote: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const modelCompose = {
|
||||||
|
services: {
|
||||||
|
app: {
|
||||||
|
image: "my-chat-app",
|
||||||
|
models: ["llm"],
|
||||||
|
},
|
||||||
|
worker: {
|
||||||
|
image: "my-worker",
|
||||||
|
models: {
|
||||||
|
llm: {
|
||||||
|
endpoint_var: "LLM_URL",
|
||||||
|
model_var: "LLM_MODEL",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
llm: {
|
||||||
|
model: "ai/smollm2",
|
||||||
|
context_size: 2048,
|
||||||
|
runtime_flags: ["--verbose"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies ComposeSpecification;
|
||||||
|
|
||||||
|
test("bundled compose schema declares models", () => {
|
||||||
|
expect(composeSpec.properties).toHaveProperty("models");
|
||||||
|
expect(composeSpec.definitions.model.required).toEqual(["model"]);
|
||||||
|
expect(composeSpec.definitions.service.properties).toHaveProperty("models");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("suffixing does not rename model identifiers or drop model config", () => {
|
||||||
|
const updated = addSuffixToAllProperties(
|
||||||
|
structuredClone(modelCompose),
|
||||||
|
"testhash",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updated.models?.llm?.model).toBe("ai/smollm2");
|
||||||
|
expect(updated.models?.llm?.context_size).toBe(2048);
|
||||||
|
expect(updated.models?.llm?.runtime_flags).toEqual(["--verbose"]);
|
||||||
|
expect(updated.services?.["app-testhash"]?.models).toEqual(["llm"]);
|
||||||
|
expect(updated.services?.["worker-testhash"]?.models).toEqual({
|
||||||
|
llm: {
|
||||||
|
endpoint_var: "LLM_URL",
|
||||||
|
model_var: "LLM_MODEL",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(updated.services).not.toHaveProperty("app");
|
||||||
|
expect(updated.models).not.toHaveProperty("llm-testhash");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isolated deployment preserves model identifiers", () => {
|
||||||
|
const updated = addAppNameToPreventCollision(
|
||||||
|
structuredClone(modelCompose),
|
||||||
|
"chat-app",
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updated.models?.llm?.model).toBe("ai/smollm2");
|
||||||
|
expect(updated.services?.app?.models).toEqual(["llm"]);
|
||||||
|
expect(updated.services?.worker?.models).toEqual({
|
||||||
|
llm: {
|
||||||
|
endpoint_var: "LLM_URL",
|
||||||
|
model_var: "LLM_MODEL",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("raw remote compose conversion preserves models", async () => {
|
||||||
|
const converted = await addDomainToCompose(
|
||||||
|
{
|
||||||
|
appName: "chat-app",
|
||||||
|
composeFile: stringify(modelCompose, { lineWidth: 1000 }),
|
||||||
|
composePath: "./docker-compose.yml",
|
||||||
|
composeType: "docker-compose",
|
||||||
|
isolatedDeployment: false,
|
||||||
|
isolatedDeploymentsVolume: false,
|
||||||
|
randomize: false,
|
||||||
|
serverId: "remote-server",
|
||||||
|
sourceType: "raw",
|
||||||
|
suffix: "",
|
||||||
|
} as unknown as Compose,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(execAsyncRemote).not.toHaveBeenCalled();
|
||||||
|
expect(converted?.models?.llm?.model).toBe("ai/smollm2");
|
||||||
|
expect(converted?.services?.app?.models).toEqual(["llm"]);
|
||||||
|
expect(converted?.services?.worker?.models).toEqual({
|
||||||
|
llm: {
|
||||||
|
endpoint_var: "LLM_URL",
|
||||||
|
model_var: "LLM_MODEL",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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" }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -166,6 +166,16 @@ describe("ovhClient.listZones", () => {
|
|||||||
|
|
||||||
await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature");
|
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", () => {
|
describe("ovhClient.listRecords", () => {
|
||||||
@@ -578,4 +588,55 @@ describe("ovhClient.testConnection", () => {
|
|||||||
"Invalid signature",
|
"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`,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -111,6 +111,54 @@ describe("route53Client.listRecords", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("decodes octal escapes such as \\052 for wildcard names", async () => {
|
||||||
|
send.mockResolvedValueOnce({
|
||||||
|
ResourceRecordSets: [
|
||||||
|
{
|
||||||
|
Name: "\\052.example.com.",
|
||||||
|
Type: "A",
|
||||||
|
TTL: 300,
|
||||||
|
ResourceRecords: [{ Value: "1.2.3.4" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
IsTruncated: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const records = await route53Client.listRecords(config, "Z123");
|
||||||
|
|
||||||
|
expect(records[0]).toMatchObject({
|
||||||
|
id: "A:*.example.com",
|
||||||
|
name: "*.example.com",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps escaped dots so they stay distinct from label separators", async () => {
|
||||||
|
send.mockResolvedValueOnce({
|
||||||
|
ResourceRecordSets: [
|
||||||
|
{
|
||||||
|
Name: "a\\056b.example.com.",
|
||||||
|
Type: "A",
|
||||||
|
TTL: 300,
|
||||||
|
ResourceRecords: [{ Value: "1.2.3.4" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "a.b.example.com.",
|
||||||
|
Type: "A",
|
||||||
|
TTL: 300,
|
||||||
|
ResourceRecords: [{ Value: "5.6.7.8" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
IsTruncated: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const records = await route53Client.listRecords(config, "Z123");
|
||||||
|
|
||||||
|
expect(records.map((r) => r.id)).toEqual([
|
||||||
|
"A:a\\056b.example.com",
|
||||||
|
"A:a.b.example.com",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("skips record sets with no ResourceRecords (e.g. alias targets)", async () => {
|
it("skips record sets with no ResourceRecords (e.g. alias targets)", async () => {
|
||||||
send.mockResolvedValueOnce({
|
send.mockResolvedValueOnce({
|
||||||
ResourceRecordSets: [
|
ResourceRecordSets: [
|
||||||
@@ -355,6 +403,24 @@ describe("route53Client.deleteRecord", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("finds a wildcard record set that Route53 returns escaped", async () => {
|
||||||
|
const existingSet = {
|
||||||
|
Name: "\\052.example.com.",
|
||||||
|
Type: "A",
|
||||||
|
TTL: 300,
|
||||||
|
ResourceRecords: [{ Value: "1.2.3.4" }],
|
||||||
|
};
|
||||||
|
send.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] });
|
||||||
|
send.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await route53Client.deleteRecord(config, "Z123", "A:*.example.com");
|
||||||
|
|
||||||
|
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
|
||||||
|
expect(changeCommand.input.ChangeBatch.Changes).toEqual([
|
||||||
|
{ Action: "DELETE", ResourceRecordSet: existingSet },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("throws when the record no longer exists", async () => {
|
it("throws when the record no longer exists", async () => {
|
||||||
send.mockResolvedValueOnce({ ResourceRecordSets: [] });
|
send.mockResolvedValueOnce({ ResourceRecordSets: [] });
|
||||||
|
|
||||||
|
|||||||
+71
@@ -472,6 +472,77 @@ describe("infisical client", () => {
|
|||||||
expect(params.get("secretPath")).toBe("/frontend");
|
expect(params.get("secretPath")).toBe("/frontend");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("asks for imported secrets and merges them", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
secrets: [],
|
||||||
|
imports: [
|
||||||
|
{
|
||||||
|
secretPath: "/shared",
|
||||||
|
secrets: [
|
||||||
|
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||||
|
|
||||||
|
expect(result).toEqual({ DB_URL: "postgres://imported" });
|
||||||
|
const [listUrl] = mockFetch.mock.calls[1] as [string];
|
||||||
|
// snake_case on purpose: `includeImports` is silently ignored and the
|
||||||
|
// response comes back with an empty `imports` array.
|
||||||
|
expect(new URL(listUrl).searchParams.get("include_imports")).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the later import win when two define the same key", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
secrets: [],
|
||||||
|
imports: [
|
||||||
|
{
|
||||||
|
secretPath: "/base",
|
||||||
|
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://base" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
secretPath: "/override",
|
||||||
|
secrets: [
|
||||||
|
{ secretKey: "DB_URL", secretValue: "postgres://override" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||||
|
|
||||||
|
// Infisical documents this order: "If two imports carry a secret with the
|
||||||
|
// same name, the value from the bottom-most import wins." The response
|
||||||
|
// lists imports in that order, so assigning them in sequence matches it.
|
||||||
|
expect(result).toEqual({ DB_URL: "postgres://override" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a folder's own secret win over an imported one of the same name", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://local" }],
|
||||||
|
imports: [
|
||||||
|
{
|
||||||
|
secretPath: "/shared",
|
||||||
|
secrets: [
|
||||||
|
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||||
|
|
||||||
|
expect(result).toEqual({ DB_URL: "postgres://local" });
|
||||||
|
});
|
||||||
|
|
||||||
it("throws a clear error for a missing secret", async () => {
|
it("throws a clear error for a missing secret", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce(loginResponse())
|
.mockResolvedValueOnce(loginResponse())
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
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 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
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 }}");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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,6 +73,20 @@ describe("owner and admin bypass enterprise resources", () => {
|
|||||||
).resolves.toBeUndefined();
|
).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 () => {
|
it("owner bypasses multiple enterprise permissions at once", async () => {
|
||||||
memberToReturn = mockMemberData("owner");
|
memberToReturn = mockMemberData("owner");
|
||||||
await expect(
|
await expect(
|
||||||
@@ -139,6 +153,20 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
|
|||||||
checkPermission(ctx, { registry: ["create"] }),
|
checkPermission(ctx, { registry: ["create"] }),
|
||||||
).rejects.toThrow();
|
).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", () => {
|
describe("static roles validate free-tier resources", () => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const FREE_TIER_RESOURCES = [
|
|||||||
"gitProviders",
|
"gitProviders",
|
||||||
"traefikFiles",
|
"traefikFiles",
|
||||||
"api",
|
"api",
|
||||||
|
"telemetryProvider",
|
||||||
];
|
];
|
||||||
|
|
||||||
const ENTERPRISE_RESOURCES = [
|
const ENTERPRISE_RESOURCES = [
|
||||||
@@ -41,6 +42,7 @@ const ENTERPRISE_RESOURCES = [
|
|||||||
"auditLog",
|
"auditLog",
|
||||||
"vaultProvider",
|
"vaultProvider",
|
||||||
"dnsProvider",
|
"dnsProvider",
|
||||||
|
"ai",
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("enterpriseOnlyResources set", () => {
|
describe("enterpriseOnlyResources set", () => {
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ describe("enterprise resources for static roles", () => {
|
|||||||
expect(perms.destination.read).toBe(false);
|
expect(perms.destination.read).toBe(false);
|
||||||
expect(perms.notification.read).toBe(false);
|
expect(perms.notification.read).toBe(false);
|
||||||
expect(perms.auditLog.read).toBe(false);
|
expect(perms.auditLog.read).toBe(false);
|
||||||
|
expect(perms.telemetryProvider.read).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const baseSettings: WebServerSettings = {
|
|||||||
letsEncryptEmail: null,
|
letsEncryptEmail: null,
|
||||||
sshPrivateKey: null,
|
sshPrivateKey: null,
|
||||||
enableDockerCleanup: false,
|
enableDockerCleanup: false,
|
||||||
|
telemetryProviderIds: [],
|
||||||
|
vectorAgentOrganizationId: null,
|
||||||
buildsConcurrency: 1,
|
buildsConcurrency: 1,
|
||||||
logCleanupCron: null,
|
logCleanupCron: null,
|
||||||
metricsConfig: {
|
metricsConfig: {
|
||||||
@@ -62,6 +64,10 @@ const baseSettings: WebServerSettings = {
|
|||||||
errorPageDescription: null,
|
errorPageDescription: null,
|
||||||
ogImageUrl: null,
|
ogImageUrl: null,
|
||||||
footerText: null,
|
footerText: null,
|
||||||
|
logoLightUrl: null,
|
||||||
|
loginLogoLightUrl: null,
|
||||||
|
faviconLightUrl: null,
|
||||||
|
dualModeBranding: false,
|
||||||
},
|
},
|
||||||
cleanupCacheApplications: false,
|
cleanupCacheApplications: false,
|
||||||
cleanupCacheOnCompose: false,
|
cleanupCacheOnCompose: false,
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// apiReadTraefikConfig validates against the real local paths(), which outside
|
||||||
|
// production live under the working directory.
|
||||||
|
const traefikPath = vi.hoisted(() => `${process.cwd()}/.docker/traefik`);
|
||||||
|
|
||||||
|
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: traefikPath }),
|
||||||
|
...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]);
|
||||||
|
|
||||||
|
const filePath = path.join(traefikPath, "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(traefikPath, "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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/server", () => ({
|
||||||
|
findServerById: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { pipeBetweenServers } from "@dokploy/server/utils/process/remoteStream";
|
||||||
|
|
||||||
|
describe("pipeBetweenServers", () => {
|
||||||
|
it("delivers a short source stream that ends before the target is ready", async () => {
|
||||||
|
const bytes = await pipeBetweenServers({
|
||||||
|
source: { serverId: null, command: "printf 'hello world'" },
|
||||||
|
target: { serverId: null, command: "sleep 0.3; cat > /dev/null" },
|
||||||
|
});
|
||||||
|
expect(bytes).toBe(11);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
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");
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
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");
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import http from "node:http";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import WebSocket from "ws";
|
||||||
|
|
||||||
|
class FakeChild extends EventEmitter {
|
||||||
|
stdout = new EventEmitter();
|
||||||
|
stderr = new EventEmitter();
|
||||||
|
killed = false;
|
||||||
|
exitCode: number | null = null;
|
||||||
|
signalCode: NodeJS.Signals | null = null;
|
||||||
|
signals: string[] = [];
|
||||||
|
// Mirrors ChildProcess: `killed` flips as soon as a signal is delivered,
|
||||||
|
// even if the process ignores it and keeps running.
|
||||||
|
ignoreSigterm = false;
|
||||||
|
|
||||||
|
kill(signal: NodeJS.Signals = "SIGTERM") {
|
||||||
|
this.signals.push(signal);
|
||||||
|
this.killed = true;
|
||||||
|
if (signal === "SIGTERM" && this.ignoreSigterm) return true;
|
||||||
|
this.signalCode = signal;
|
||||||
|
this.emit("close", null, signal);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = vi.hoisted(() => [] as FakeChild[]);
|
||||||
|
const nextChild = vi.hoisted(() => ({ ignoreSigterm: false }));
|
||||||
|
vi.mock("node:child_process", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("node:child_process")>()),
|
||||||
|
spawn: vi.fn(() => {
|
||||||
|
const child = new FakeChild();
|
||||||
|
child.ignoreSigterm = nextChild.ignoreSigterm;
|
||||||
|
children.push(child);
|
||||||
|
return child;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sshClients = vi.hoisted(
|
||||||
|
() => [] as { connect: ReturnType<typeof vi.fn> }[],
|
||||||
|
);
|
||||||
|
vi.mock("ssh2", () => ({
|
||||||
|
Client: vi.fn(function (this: Record<string, unknown>) {
|
||||||
|
this.on = vi.fn(() => this);
|
||||||
|
this.connect = vi.fn(() => this);
|
||||||
|
this.end = vi.fn();
|
||||||
|
sshClients.push(this as never);
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockValidateRequest = vi.hoisted(() => vi.fn());
|
||||||
|
const mockFindServerById = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@dokploy/server", () => ({
|
||||||
|
IS_CLOUD: false,
|
||||||
|
validateRequest: mockValidateRequest,
|
||||||
|
findServerById: mockFindServerById,
|
||||||
|
}));
|
||||||
|
vi.mock("@dokploy/server/wss/utils", () => ({
|
||||||
|
readValidDirectory: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { setupDeploymentLogsWebSocketServer } from "@/server/wss/listen-deployment";
|
||||||
|
|
||||||
|
const AUTH = {
|
||||||
|
user: { id: "user-1" },
|
||||||
|
session: { activeOrganizationId: "org-1" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const deferred = <T>() => {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
};
|
||||||
|
|
||||||
|
const flush = () => new Promise((r) => setTimeout(r, 50));
|
||||||
|
|
||||||
|
let server: http.Server;
|
||||||
|
let url: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
children.length = 0;
|
||||||
|
sshClients.length = 0;
|
||||||
|
nextChild.ignoreSigterm = false;
|
||||||
|
vi.clearAllMocks();
|
||||||
|
server = http.createServer();
|
||||||
|
setupDeploymentLogsWebSocketServer(server);
|
||||||
|
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
||||||
|
const { port } = server.address() as AddressInfo;
|
||||||
|
url = `ws://127.0.0.1:${port}/listen-deployment?logPath=/etc/dokploy/logs/app/deploy.log`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
await new Promise((r) => server.close(r));
|
||||||
|
});
|
||||||
|
|
||||||
|
const connect = async (query = "") => {
|
||||||
|
const ws = new WebSocket(`${url}${query}`);
|
||||||
|
await new Promise((r) => ws.once("open", r));
|
||||||
|
return ws;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeClient = async (ws: WebSocket) => {
|
||||||
|
ws.close();
|
||||||
|
await new Promise((r) => ws.once("close", r));
|
||||||
|
await flush();
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("deployment logs websocket (#5518)", () => {
|
||||||
|
it("kills tail when the client disconnects", async () => {
|
||||||
|
mockValidateRequest.mockResolvedValue(AUTH);
|
||||||
|
const ws = await connect();
|
||||||
|
await flush();
|
||||||
|
expect(children).toHaveLength(1);
|
||||||
|
|
||||||
|
await closeClient(ws);
|
||||||
|
expect(children[0]!.signals).toContain("SIGTERM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not spawn tail when the client disconnects during auth", async () => {
|
||||||
|
const auth = deferred<typeof AUTH>();
|
||||||
|
mockValidateRequest.mockReturnValue(auth.promise);
|
||||||
|
const ws = await connect();
|
||||||
|
|
||||||
|
await closeClient(ws);
|
||||||
|
auth.resolve(AUTH);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const leaked = children.filter(
|
||||||
|
(c) => c.exitCode === null && c.signalCode === null,
|
||||||
|
);
|
||||||
|
expect(leaked).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open an SSH session when the client disconnects while loading the server", async () => {
|
||||||
|
mockValidateRequest.mockResolvedValue(AUTH);
|
||||||
|
const serverLookup = deferred<unknown>();
|
||||||
|
mockFindServerById.mockReturnValue(serverLookup.promise);
|
||||||
|
const ws = await connect("&serverId=srv-1");
|
||||||
|
|
||||||
|
await closeClient(ws);
|
||||||
|
serverLookup.resolve({
|
||||||
|
organizationId: "org-1",
|
||||||
|
sshKeyId: "key-1",
|
||||||
|
sshKey: { privateKey: "key" },
|
||||||
|
ipAddress: "10.0.0.1",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const connected = sshClients.filter((c) => c.connect.mock.calls.length > 0);
|
||||||
|
expect(connected).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("force-kills tail with SIGKILL when SIGTERM does not stop it", async () => {
|
||||||
|
nextChild.ignoreSigterm = true;
|
||||||
|
mockValidateRequest.mockResolvedValue(AUTH);
|
||||||
|
const ws = await connect();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
await closeClient(ws);
|
||||||
|
await new Promise((r) => setTimeout(r, 1100));
|
||||||
|
expect(children[0]!.signals).toEqual(["SIGTERM", "SIGKILL"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -84,9 +84,9 @@ export const ShowPatches = ({ id, type }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
||||||
<div>
|
<div className="flex flex-col gap-1">
|
||||||
<CardTitle>Patches</CardTitle>
|
<CardTitle className="text-xl">Patches</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Apply code patches to your repository during build. Patches are
|
Apply code patches to your repository during build. Patches are
|
||||||
applied after cloning the repository and before building.
|
applied after cloning the repository and before building.
|
||||||
|
|||||||
@@ -77,23 +77,19 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
<Card className="bg-background h-full min-h-[50vh]">
|
||||||
<CardHeader className="px-0">
|
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
||||||
<div className="flex justify-between items-center gap-y-2 flex-wrap">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex flex-col gap-2">
|
<CardTitle className="text-xl">Scheduled Tasks</CardTitle>
|
||||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
<CardDescription>
|
||||||
Scheduled Tasks
|
Schedule tasks to run automatically at specified intervals.
|
||||||
</CardTitle>
|
</CardDescription>
|
||||||
<CardDescription>
|
|
||||||
Schedule tasks to run automatically at specified intervals.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
{schedules && schedules.length > 0 && (
|
|
||||||
<HandleSchedules id={id} scheduleType={scheduleType} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{schedules && schedules.length > 0 && (
|
||||||
|
<HandleSchedules id={id} scheduleType={scheduleType} />
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-0">
|
<CardContent>
|
||||||
{isLoadingSchedules ? (
|
{isLoadingSchedules ? (
|
||||||
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||||
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||||
|
|||||||
+18
-27
@@ -77,35 +77,26 @@ export const ShowVolumeBackups = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
<Card className="bg-background h-full min-h-[50vh]">
|
||||||
<CardHeader className="px-0">
|
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
||||||
<div className="flex justify-between items-center flex-wrap gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex flex-col gap-2">
|
<CardTitle className="text-xl">Volume Backups</CardTitle>
|
||||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
<CardDescription>
|
||||||
Volume Backups
|
Schedule volume backups to run automatically at specified intervals
|
||||||
</CardTitle>
|
</CardDescription>
|
||||||
<CardDescription>
|
</div>
|
||||||
Schedule volume backups to run automatically at specified
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
intervals
|
{volumeBackups && volumeBackups.length > 0 && (
|
||||||
</CardDescription>
|
<>
|
||||||
</div>
|
<HandleVolumeBackups id={id} volumeBackupType={type} />
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2">
|
||||||
{volumeBackups && volumeBackups.length > 0 && (
|
<RestoreVolumeBackups id={id} type={type} serverId={serverId} />
|
||||||
<>
|
</div>
|
||||||
<HandleVolumeBackups id={id} volumeBackupType={type} />
|
</>
|
||||||
<div className="flex items-center gap-2">
|
)}
|
||||||
<RestoreVolumeBackups
|
|
||||||
id={id}
|
|
||||||
type={type}
|
|
||||||
serverId={serverId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-0">
|
<CardContent>
|
||||||
{isLoadingVolumeBackups ? (
|
{isLoadingVolumeBackups ? (
|
||||||
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||||
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -30,6 +31,7 @@ interface Props {
|
|||||||
|
|
||||||
const AddRedirectSchema = z.object({
|
const AddRedirectSchema = z.object({
|
||||||
command: z.string(),
|
command: z.string(),
|
||||||
|
pullImages: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
||||||
@@ -57,22 +59,25 @@ export const AddCommandCompose = ({ composeId }: Props) => {
|
|||||||
const form = useForm<AddCommand>({
|
const form = useForm<AddCommand>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
command: "",
|
command: "",
|
||||||
|
pullImages: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(AddRedirectSchema),
|
resolver: zodResolver(AddRedirectSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.command) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
command: data?.command || "",
|
command: data.command || "",
|
||||||
|
pullImages: data.pullImages ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
|
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
||||||
|
|
||||||
const onSubmit = async (data: AddCommand) => {
|
const onSubmit = async (formData: AddCommand) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
composeId,
|
composeId,
|
||||||
command: data?.command,
|
command: formData.command,
|
||||||
|
pullImages: formData.pullImages,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Command Updated");
|
toast.success("Command Updated");
|
||||||
@@ -109,6 +114,30 @@ export const AddCommandCompose = ({ composeId }: Props) => {
|
|||||||
<strong>docker</strong>.
|
<strong>docker</strong>.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
{data?.composeType === "docker-compose" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="pullImages"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-xs">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Pull latest images on deploy</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Adds <strong>--pull always</strong> to the default
|
||||||
|
command so every deploy fetches the newest image for
|
||||||
|
each tag. Has no effect when a custom command is set.
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="command"
|
name="command"
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
|
import { AlertTriangle, Copy, HardDriveDownload } from "lucide-react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const freshVolumesSchema = z.object({
|
||||||
|
confirmName: z.string().min(1, {
|
||||||
|
message: "Compose name is required",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FreshVolumesForm = z.infer<typeof freshVolumesSchema>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
composeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FreshVolumes = ({ composeId }: Props) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
const canDeploy = permissions?.deployment.create ?? false;
|
||||||
|
const { data, refetch } = api.compose.one.useQuery(
|
||||||
|
{ composeId },
|
||||||
|
{ enabled: !!composeId },
|
||||||
|
);
|
||||||
|
const { mutateAsync: deploy, isPending } = api.compose.deploy.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<FreshVolumesForm>({
|
||||||
|
defaultValues: { confirmName: "" },
|
||||||
|
resolver: zodResolver(freshVolumesSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedName = `${data?.name}/${data?.appName}`;
|
||||||
|
const isRunning = data?.composeStatus === "running";
|
||||||
|
|
||||||
|
const onSubmit = async (formData: FreshVolumesForm) => {
|
||||||
|
if (formData.confirmName !== expectedName) {
|
||||||
|
form.setError("confirmName", {
|
||||||
|
message: `Compose name must match "${expectedName}"`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await deploy({ composeId, freshVolumes: true })
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Compose deployed with fresh volumes");
|
||||||
|
setIsOpen(false);
|
||||||
|
form.reset();
|
||||||
|
refetch();
|
||||||
|
router.push(
|
||||||
|
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deploying compose");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!canDeploy || data?.composeType !== "docker-compose") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-background border-destructive/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
|
Danger Zone
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-base font-semibold">
|
||||||
|
Deploy with Fresh Volumes
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Permanently wipes every volume of this compose and redeploys it
|
||||||
|
from a clean state. All persistent data (databases, uploads,
|
||||||
|
caches) will be lost.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsOpen(open);
|
||||||
|
if (!open) form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
isLoading={isPending}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-destructive/50 hover:bg-destructive/10 hover:text-destructive text-destructive"
|
||||||
|
>
|
||||||
|
<HardDriveDownload className="mr-2 h-4 w-4" />
|
||||||
|
Deploy with Fresh Volumes
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
|
Are you absolutely sure?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>This action will:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
<li>Stop the current compose</li>
|
||||||
|
<li>Delete all volumes and their data</li>
|
||||||
|
<li>Redeploy the compose with empty volumes</li>
|
||||||
|
</ul>
|
||||||
|
<p className="font-medium text-destructive mt-4">
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
id="hook-form-fresh-volumes"
|
||||||
|
className="grid w-full gap-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="confirmName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
<span>
|
||||||
|
To confirm, type{" "}
|
||||||
|
<Badge
|
||||||
|
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (data?.name && data?.appName) {
|
||||||
|
copy(expectedName);
|
||||||
|
toast.success(
|
||||||
|
"Copied to clipboard. Be careful!",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expectedName}
|
||||||
|
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
|
||||||
|
</Badge>{" "}
|
||||||
|
in the box below:
|
||||||
|
</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter compose name to confirm"
|
||||||
|
autoComplete="off"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
{isRunning && (
|
||||||
|
<AlertBlock type="warning" className="w-full">
|
||||||
|
Cannot deploy with fresh volumes while a deployment is
|
||||||
|
running. Please wait for it to finish and then try again.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="secondary" onClick={() => setIsOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isLoading={isPending}
|
||||||
|
disabled={isRunning}
|
||||||
|
form="hook-form-fresh-volumes"
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Wipe volumes and deploy
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
|
||||||
Ban,
|
|
||||||
CheckCircle2,
|
|
||||||
HardDriveDownload,
|
|
||||||
RefreshCcw,
|
|
||||||
Rocket,
|
|
||||||
Terminal,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -89,51 +82,6 @@ export const ComposeActions = ({ composeId }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
)}
|
)}
|
||||||
{canDeploy && data?.composeType === "docker-compose" && (
|
|
||||||
<DialogAction
|
|
||||||
title="Deploy with Fresh Volumes"
|
|
||||||
description="This will remove all volumes and redeploy with a clean state. All persistent data will be permanently deleted."
|
|
||||||
type="destructive"
|
|
||||||
onClick={async () => {
|
|
||||||
await deploy({
|
|
||||||
composeId: composeId,
|
|
||||||
freshVolumes: true,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success("Compose deployed with fresh volumes");
|
|
||||||
refetch();
|
|
||||||
router.push(
|
|
||||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error deploying compose");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
isLoading={data?.composeStatus === "running"}
|
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<HardDriveDownload className="size-4 mr-1" />
|
|
||||||
Fresh Volumes
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipPrimitive.Portal>
|
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
|
||||||
<p>
|
|
||||||
Deploy with fresh volumes (removes all persistent data)
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPrimitive.Portal>
|
|
||||||
</Tooltip>
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
)}
|
|
||||||
{canDeploy && (
|
{canDeploy && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Rebuild Compose"
|
title="Rebuild Compose"
|
||||||
|
|||||||
-1
@@ -158,7 +158,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
composePath: data.composePath,
|
composePath: data.composePath,
|
||||||
composeId,
|
composeId,
|
||||||
sourceType: "bitbucket",
|
sourceType: "bitbucket",
|
||||||
composeStatus: "idle",
|
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
|
|||||||
-1
@@ -97,7 +97,6 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
|||||||
composeId,
|
composeId,
|
||||||
sourceType: "git",
|
sourceType: "git",
|
||||||
composePath: values.composePath,
|
composePath: values.composePath,
|
||||||
composeStatus: "idle",
|
|
||||||
watchPaths: values.watchPaths || [],
|
watchPaths: values.watchPaths || [],
|
||||||
enableSubmodules: values.enableSubmodules,
|
enableSubmodules: values.enableSubmodules,
|
||||||
})
|
})
|
||||||
|
|||||||
-1
@@ -157,7 +157,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
giteaId: data.giteaId,
|
giteaId: data.giteaId,
|
||||||
composeId,
|
composeId,
|
||||||
sourceType: "gitea",
|
sourceType: "gitea",
|
||||||
composeStatus: "idle",
|
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
} as any)
|
} as any)
|
||||||
|
|||||||
-1
@@ -155,7 +155,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
composePath: data.composePath,
|
composePath: data.composePath,
|
||||||
githubId: data.githubId,
|
githubId: data.githubId,
|
||||||
sourceType: "github",
|
sourceType: "github",
|
||||||
composeStatus: "idle",
|
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
triggerType: data.triggerType,
|
triggerType: data.triggerType,
|
||||||
|
|||||||
-1
@@ -169,7 +169,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
gitlabProjectId: data.repository.id,
|
gitlabProjectId: data.repository.id,
|
||||||
gitlabPathNamespace: data.repository.gitlabPathNamespace,
|
gitlabPathNamespace: data.repository.gitlabPathNamespace,
|
||||||
sourceType: "gitlab",
|
sourceType: "gitlab",
|
||||||
composeStatus: "idle",
|
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -554,7 +554,10 @@ export const RestoreBackup = ({
|
|||||||
value={field.value}
|
value={field.value}
|
||||||
onValueChange={(value: DatabaseType) => {
|
onValueChange={(value: DatabaseType) => {
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("metadata", {});
|
form.setValue("metadata", {
|
||||||
|
serviceName:
|
||||||
|
form.getValues("metadata.serviceName") ?? "",
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -581,7 +584,7 @@ export const RestoreBackup = ({
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
value={field.value || undefined}
|
value={field.value ?? ""}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { ClipboardList, DatabaseBackup, Play, Trash2 } from "lucide-react";
|
||||||
ClipboardList,
|
|
||||||
Database,
|
|
||||||
DatabaseBackup,
|
|
||||||
Play,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -101,12 +95,9 @@ export const ShowBackups = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader className="flex flex-row justify-between gap-4 flex-wrap">
|
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-1">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl">Backups</CardTitle>
|
||||||
<Database className="size-6 text-muted-foreground" />
|
|
||||||
Backups
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Add backups to your database to save the data to a different
|
Add backups to your database to save the data to a different
|
||||||
provider.
|
provider.
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ColumnFiltersState,
|
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getFilteredRowModel,
|
|
||||||
getPaginationRowModel,
|
getPaginationRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
type PaginationState,
|
type PaginationState,
|
||||||
@@ -27,7 +25,6 @@ import Link from "next/link";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -97,14 +94,21 @@ function getServiceInfo(d: DeploymentRow) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShowDeploymentsTable() {
|
interface ShowDeploymentsTableProps {
|
||||||
|
globalFilter: string;
|
||||||
|
statusFilter: string;
|
||||||
|
typeFilter: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShowDeploymentsTable({
|
||||||
|
globalFilter,
|
||||||
|
statusFilter,
|
||||||
|
typeFilter,
|
||||||
|
}: ShowDeploymentsTableProps) {
|
||||||
const [sorting, setSorting] = useState<SortingState>([
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
{ id: "createdAt", desc: true },
|
{ id: "createdAt", desc: true },
|
||||||
]);
|
]);
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
|
||||||
const [globalFilter, setGlobalFilter] = useState("");
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
|
||||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
@@ -446,177 +450,140 @@ export function ShowDeploymentsTable() {
|
|||||||
columns,
|
columns,
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
columnFilters,
|
|
||||||
globalFilter,
|
|
||||||
pagination,
|
pagination,
|
||||||
},
|
},
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onColumnFiltersChange: setColumnFilters,
|
|
||||||
onGlobalFilterChange: setGlobalFilter,
|
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="px-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{isLoading ? (
|
||||||
<Input
|
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
|
||||||
placeholder="Search by name, project, environment, server..."
|
<Loader2 className="size-4 animate-spin" />
|
||||||
value={globalFilter}
|
<span>Loading deployments...</span>
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
</div>
|
||||||
className="max-w-xs"
|
) : (
|
||||||
/>
|
<>
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<div className="rounded-md border overflow-x-auto">
|
||||||
<SelectTrigger className="w-[140px]">
|
<Table>
|
||||||
<SelectValue placeholder="Status" />
|
<TableHeader>
|
||||||
</SelectTrigger>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<SelectContent>
|
<TableRow key={headerGroup.id}>
|
||||||
<SelectItem value="all">All statuses</SelectItem>
|
{headerGroup.headers.map((header) => (
|
||||||
<SelectItem value="running">Running</SelectItem>
|
<TableHead key={header.id}>
|
||||||
<SelectItem value="done">Done</SelectItem>
|
{header.isPlaceholder
|
||||||
<SelectItem value="error">Error</SelectItem>
|
? null
|
||||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
: flexRender(
|
||||||
</SelectContent>
|
header.column.columnDef.header,
|
||||||
</Select>
|
header.getContext(),
|
||||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
)}
|
||||||
<SelectTrigger className="w-[140px]">
|
</TableHead>
|
||||||
<SelectValue placeholder="Type" />
|
))}
|
||||||
</SelectTrigger>
|
</TableRow>
|
||||||
<SelectContent>
|
))}
|
||||||
<SelectItem value="all">All types</SelectItem>
|
</TableHeader>
|
||||||
<SelectItem value="application">Application</SelectItem>
|
<TableBody>
|
||||||
<SelectItem value="compose">Compose</SelectItem>
|
{table.getRowModel().rows?.length ? (
|
||||||
</SelectContent>
|
table.getRowModel().rows.map((row) => (
|
||||||
</Select>
|
<TableRow key={row.id}>
|
||||||
</div>
|
{row.getVisibleCells().map((cell) => (
|
||||||
<div className="px-0">
|
<TableCell key={cell.id}>
|
||||||
{isLoading ? (
|
{flexRender(
|
||||||
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
|
cell.column.columnDef.cell,
|
||||||
<Loader2 className="size-4 animate-spin" />
|
cell.getContext(),
|
||||||
<span>Loading deployments...</span>
|
)}
|
||||||
</div>
|
</TableCell>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="rounded-md border overflow-x-auto">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<TableRow key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => (
|
|
||||||
<TableHead key={header.id}>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext(),
|
|
||||||
)}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className=" text-center"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
|
||||||
|
<Rocket className="size-8" />
|
||||||
|
<p className="font-medium">No deployments found</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Deployments from applications and compose will appear
|
||||||
|
here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
Rows per page
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={String(pagination.pageSize)}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setPagination((p) => ({
|
||||||
|
...p,
|
||||||
|
pageSize: Number(value),
|
||||||
|
pageIndex: 0,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side="top">
|
||||||
|
{[10, 25, 50, 100].map((size) => (
|
||||||
|
<SelectItem key={size} value={String(size)}>
|
||||||
|
{size}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</TableHeader>
|
</SelectContent>
|
||||||
<TableBody>
|
</Select>
|
||||||
{table.getRowModel().rows?.length ? (
|
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
table.getRowModel().rows.map((row) => (
|
Showing{" "}
|
||||||
<TableRow key={row.id}>
|
{filteredData.length === 0
|
||||||
{row.getVisibleCells().map((cell) => (
|
? 0
|
||||||
<TableCell key={cell.id}>
|
: pagination.pageIndex * pagination.pageSize + 1}{" "}
|
||||||
{flexRender(
|
to{" "}
|
||||||
cell.column.columnDef.cell,
|
{Math.min(
|
||||||
cell.getContext(),
|
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||||
)}
|
filteredData.length,
|
||||||
</TableCell>
|
)}{" "}
|
||||||
))}
|
of {filteredData.length} entries
|
||||||
</TableRow>
|
</span>
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={columns.length}
|
|
||||||
className=" text-center"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
|
|
||||||
<Rocket className="size-8" />
|
|
||||||
<p className="font-medium">No deployments found</p>
|
|
||||||
<p className="text-sm">
|
|
||||||
Deployments from applications and compose will
|
|
||||||
appear here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex 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">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<Button
|
||||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
variant="outline"
|
||||||
Rows per page
|
size="sm"
|
||||||
</span>
|
className="h-8"
|
||||||
<Select
|
onClick={() => table.previousPage()}
|
||||||
value={String(pagination.pageSize)}
|
disabled={!table.getCanPreviousPage()}
|
||||||
onValueChange={(value) => {
|
>
|
||||||
setPagination((p) => ({
|
<ChevronLeft className="size-4" />
|
||||||
...p,
|
Previous
|
||||||
pageSize: Number(value),
|
</Button>
|
||||||
pageIndex: 0,
|
<Button
|
||||||
}));
|
variant="outline"
|
||||||
}}
|
size="sm"
|
||||||
>
|
className="h-8"
|
||||||
<SelectTrigger className="h-8 w-[70px]">
|
onClick={() => table.nextPage()}
|
||||||
<SelectValue />
|
disabled={!table.getCanNextPage()}
|
||||||
</SelectTrigger>
|
>
|
||||||
<SelectContent side="top">
|
Next
|
||||||
{[10, 25, 50, 100].map((size) => (
|
<ChevronRight className="size-4" />
|
||||||
<SelectItem key={size} value={String(size)}>
|
</Button>
|
||||||
{size}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
|
||||||
Showing{" "}
|
|
||||||
{filteredData.length === 0
|
|
||||||
? 0
|
|
||||||
: pagination.pageIndex * pagination.pageSize + 1}{" "}
|
|
||||||
to{" "}
|
|
||||||
{Math.min(
|
|
||||||
(pagination.pageIndex + 1) * pagination.pageSize,
|
|
||||||
filteredData.length,
|
|
||||||
)}{" "}
|
|
||||||
of {filteredData.length} entries
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
onClick={() => table.previousPage()}
|
|
||||||
disabled={!table.getCanPreviousPage()}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="size-4" />
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
onClick={() => table.nextPage()}
|
|
||||||
disabled={!table.getCanNextPage()}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ChevronRight className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,13 +45,19 @@ function StatCard({
|
|||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
delta,
|
delta,
|
||||||
|
href,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
delta?: string;
|
delta?: string;
|
||||||
|
href?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
const content = (
|
||||||
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between">
|
<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" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
@@ -63,6 +69,16 @@ function StatCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className="block h-full">
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusListCard({
|
function StatusListCard({
|
||||||
@@ -182,6 +198,7 @@ export const ShowHome = () => {
|
|||||||
label="Projects"
|
label="Projects"
|
||||||
value={String(totals.projects)}
|
value={String(totals.projects)}
|
||||||
delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
|
delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
|
||||||
|
href="/dashboard/projects"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Services"
|
label="Services"
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
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,6 +46,7 @@ import { authClient } from "@/lib/auth-client";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||||
|
import { DeleteAccountByEmail } from "./delete-account-by-email";
|
||||||
|
|
||||||
type User = typeof authClient.$Infer.Session.user;
|
type User = typeof authClient.$Infer.Session.user;
|
||||||
|
|
||||||
@@ -78,12 +79,12 @@ export const ImpersonationBar = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filteredUsers = response.data?.users.filter(
|
const filteredUsers = response.data?.users.filter(
|
||||||
// @ts-ignore
|
// @ts-expect-error
|
||||||
(user) => user.allowImpersonation && data?.user?.email !== user.email,
|
(user) => user.allowImpersonation && data?.user?.email !== user.email,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.error) {
|
if (!response.error) {
|
||||||
// @ts-ignore
|
// @ts-expect-error
|
||||||
setUsers(filteredUsers || []);
|
setUsers(filteredUsers || []);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -185,6 +186,7 @@ export const ImpersonationBar = () => {
|
|||||||
<Logo
|
<Logo
|
||||||
className="w-10 h-10"
|
className="w-10 h-10"
|
||||||
logoUrl={whitelabeling?.logoUrl || undefined}
|
logoUrl={whitelabeling?.logoUrl || undefined}
|
||||||
|
logoLightUrl={whitelabeling?.logoLightUrl || undefined}
|
||||||
/>
|
/>
|
||||||
{!isImpersonating ? (
|
{!isImpersonating ? (
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex items-center gap-2 w-full">
|
||||||
@@ -282,6 +284,7 @@ export const ImpersonationBar = () => {
|
|||||||
<Shield className="h-4 w-4" />
|
<Shield className="h-4 w-4" />
|
||||||
Impersonate
|
Impersonate
|
||||||
</Button>
|
</Button>
|
||||||
|
<DeleteAccountByEmail />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-4 w-full flex-wrap">
|
<div className="flex items-center gap-4 w-full flex-wrap">
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { displayFont } from "../font";
|
import { displayFont } from "../font";
|
||||||
|
|
||||||
const stripePromise = loadStripe(
|
const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
|
? loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY)
|
||||||
);
|
: null;
|
||||||
|
|
||||||
type Tier = "hobby" | "startup";
|
type Tier = "hobby" | "startup";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Rocket } from "lucide-react";
|
import { Rocket } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { useState } from "react";
|
||||||
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
|
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
|
||||||
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
|
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +9,14 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
const SUBTAB_VALUES = ["deployments", "queue"] as const;
|
const SUBTAB_VALUES = ["deployments", "queue"] as const;
|
||||||
@@ -26,6 +35,10 @@ export const ShowOverviewDeployments = () => {
|
|||||||
? router.query.subtab
|
? router.query.subtab
|
||||||
: DEFAULT_SUBTAB;
|
: DEFAULT_SUBTAB;
|
||||||
|
|
||||||
|
const [globalFilter, setGlobalFilter] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||||
|
|
||||||
const setSubtab = (value: string) => {
|
const setSubtab = (value: string) => {
|
||||||
if (!isValidSubtab(value)) return;
|
if (!isValidSubtab(value)) return;
|
||||||
const { subtab: _current, ...query } = router.query;
|
const { subtab: _current, ...query } = router.query;
|
||||||
@@ -59,12 +72,50 @@ export const ShowOverviewDeployments = () => {
|
|||||||
onValueChange={setSubtab}
|
onValueChange={setSubtab}
|
||||||
className="w-full min-w-0"
|
className="w-full min-w-0"
|
||||||
>
|
>
|
||||||
<TabsList className="mt-2">
|
<div className="flex flex-col lg:flex-row lg:items-center gap-y-4 gap-x-3 mt-2">
|
||||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
<TabsList className="self-start shrink-0">
|
||||||
<TabsTrigger value="queue">Queue</TabsTrigger>
|
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||||
</TabsList>
|
<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>
|
||||||
<TabsContent value="deployments" className="mt-0 min-w-0 pt-4">
|
<TabsContent value="deployments" className="mt-0 min-w-0 pt-4">
|
||||||
<ShowDeploymentsTable />
|
<ShowDeploymentsTable
|
||||||
|
globalFilter={globalFilter}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
typeFilter={typeFilter}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="queue" className="mt-0 pt-4">
|
<TabsContent value="queue" className="mt-0 pt-4">
|
||||||
<ShowQueueTable />
|
<ShowQueueTable />
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import { HandleAiProviders } from "./handle-ai-providers";
|
|||||||
export const AiForm = () => {
|
export const AiForm = () => {
|
||||||
const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
|
const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
|
||||||
const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
|
const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
|
||||||
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
const { data: currentUser } = api.user.get.useQuery();
|
const { data: currentUser } = api.user.get.useQuery();
|
||||||
|
|
||||||
const isOrgAdmin =
|
const isOrgAdmin =
|
||||||
currentUser?.role === "owner" || currentUser?.role === "admin";
|
currentUser?.role === "owner" || currentUser?.role === "admin";
|
||||||
|
|
||||||
@@ -36,7 +38,9 @@ export const AiForm = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
{isOrgAdmin && <HandleAiProviders />}
|
{isOrgAdmin && <HandleAiProviders />}
|
||||||
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
|
{permissions?.ai.create && aiConfigs && aiConfigs?.length > 0 && (
|
||||||
|
<HandleAi />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 py-8 border-t">
|
<CardContent className="space-y-2 py-8 border-t">
|
||||||
@@ -53,7 +57,7 @@ export const AiForm = () => {
|
|||||||
<span className="text-base text-muted-foreground text-center">
|
<span className="text-base text-muted-foreground text-center">
|
||||||
You don't have any AI configurations
|
You don't have any AI configurations
|
||||||
</span>
|
</span>
|
||||||
<HandleAi />
|
{permissions?.ai.create && <HandleAi />}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4 rounded-lg min-h-[25vh]">
|
<div className="flex flex-col gap-4 rounded-lg min-h-[25vh]">
|
||||||
@@ -70,33 +74,37 @@ export const AiForm = () => {
|
|||||||
<CardDescription>{config.model}</CardDescription>
|
<CardDescription>{config.model}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<HandleAi aiId={config.aiId} />
|
{permissions?.ai.update && (
|
||||||
<DialogAction
|
<HandleAi aiId={config.aiId} />
|
||||||
title="Delete AI"
|
)}
|
||||||
description="Are you sure you want to delete this AI?"
|
{permissions?.ai.delete && (
|
||||||
type="destructive"
|
<DialogAction
|
||||||
onClick={async () => {
|
title="Delete AI"
|
||||||
await mutateAsync({
|
description="Are you sure you want to delete this AI?"
|
||||||
aiId: config.aiId,
|
type="destructive"
|
||||||
})
|
onClick={async () => {
|
||||||
.then(() => {
|
await mutateAsync({
|
||||||
toast.success("AI deleted successfully");
|
aiId: config.aiId,
|
||||||
refetch();
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then(() => {
|
||||||
toast.error("Error deleting AI");
|
toast.success("AI deleted successfully");
|
||||||
});
|
refetch();
|
||||||
}}
|
})
|
||||||
>
|
.catch(() => {
|
||||||
<Button
|
toast.error("Error deleting AI");
|
||||||
variant="ghost"
|
});
|
||||||
size="icon"
|
}}
|
||||||
className="group hover:bg-red-500/10 "
|
|
||||||
isLoading={isRemoving}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
</DialogAction>
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10 "
|
||||||
|
isLoading={isRemoving}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const stripePromise = loadStripe(
|
const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
|
? loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY)
|
||||||
);
|
: null;
|
||||||
|
|
||||||
/** Precio legacy / Hobby: $4.50/mo primer servidor, $3.50 siguientes; anual $45.90 primero, $35.70 siguientes. */
|
/** Precio legacy / Hobby: $4.50/mo primer servidor, $3.50 siguientes; anual $45.90 primero, $35.70 siguientes. */
|
||||||
export const calculatePrice = (count: number, isAnnual = false) => {
|
export const calculatePrice = (count: number, isAnnual = false) => {
|
||||||
@@ -111,14 +111,20 @@ export const ShowBilling = () => {
|
|||||||
const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null);
|
const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null);
|
||||||
const [switchingTrial, setSwitchingTrial] = useState(false);
|
const [switchingTrial, setSwitchingTrial] = useState(false);
|
||||||
|
|
||||||
const handleSwitchTrialPlan = async (tier: "hobby" | "startup") => {
|
const handleSwitchTrialPlan = async (
|
||||||
const planName = tier === "startup" ? "Startup" : "Hobby";
|
tier: "hobby" | "startup",
|
||||||
|
isAnnualPlan: boolean,
|
||||||
|
label?: string,
|
||||||
|
) => {
|
||||||
|
const planName =
|
||||||
|
label ??
|
||||||
|
`${tier === "startup" ? "Startup" : "Hobby"} ${isAnnualPlan ? "annual" : "monthly"}`;
|
||||||
|
|
||||||
const switchPlan = async () => {
|
const switchPlan = async () => {
|
||||||
await upgradeSubscription({
|
await upgradeSubscription({
|
||||||
tier,
|
tier,
|
||||||
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
|
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
|
||||||
isAnnual: false,
|
isAnnual: isAnnualPlan,
|
||||||
});
|
});
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -133,7 +139,7 @@ export const ShowBilling = () => {
|
|||||||
await toast
|
await toast
|
||||||
.promise(switchPlan(), {
|
.promise(switchPlan(), {
|
||||||
loading: `Switching to ${planName}...`,
|
loading: `Switching to ${planName}...`,
|
||||||
success: `Your trial is now on the ${planName} plan`,
|
success: `Your trial is now on ${planName}`,
|
||||||
error: (error) =>
|
error: (error) =>
|
||||||
error instanceof Error ? error.message : "Error switching plan",
|
error instanceof Error ? error.message : "Error switching plan",
|
||||||
})
|
})
|
||||||
@@ -336,6 +342,11 @@ export const ShowBilling = () => {
|
|||||||
<Badge className="capitalize" variant="secondary">
|
<Badge className="capitalize" variant="secondary">
|
||||||
{billingStatus.plan ?? "Trial"}
|
{billingStatus.plan ?? "Trial"}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{billingStatus.plan && (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{billingStatus.isAnnual ? "Annual" : "Monthly"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{billingStatus.isOnTrial
|
{billingStatus.isOnTrial
|
||||||
@@ -356,23 +367,45 @@ export const ShowBilling = () => {
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{billingStatus.isOnTrial &&
|
{billingStatus.isOnTrial &&
|
||||||
useNewPricing &&
|
useNewPricing &&
|
||||||
billingStatus.plan && (
|
billingStatus.plan &&
|
||||||
<Button
|
billingStatus.plan !== "legacy" && (
|
||||||
size="sm"
|
<>
|
||||||
variant="outline"
|
<Button
|
||||||
isLoading={switchingTrial}
|
size="sm"
|
||||||
onClick={() =>
|
variant="outline"
|
||||||
handleSwitchTrialPlan(
|
isLoading={switchingTrial}
|
||||||
billingStatus.plan === "startup"
|
onClick={() =>
|
||||||
? "hobby"
|
handleSwitchTrialPlan(
|
||||||
: "startup",
|
billingStatus.plan === "startup"
|
||||||
)
|
? "hobby"
|
||||||
}
|
: "startup",
|
||||||
>
|
billingStatus.isAnnual,
|
||||||
{billingStatus.plan === "startup"
|
)
|
||||||
? "Switch to Hobby"
|
}
|
||||||
: "Switch to Startup"}
|
>
|
||||||
</Button>
|
{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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ const DnsProviderSchema = z.object({
|
|||||||
applicationKey: z.string(),
|
applicationKey: z.string(),
|
||||||
applicationSecret: z.string(),
|
applicationSecret: z.string(),
|
||||||
consumerKey: z.string(),
|
consumerKey: z.string(),
|
||||||
|
zone: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
|
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
|
||||||
@@ -104,6 +105,7 @@ const defaultValues: DnsProviderForm = {
|
|||||||
applicationKey: "",
|
applicationKey: "",
|
||||||
applicationSecret: "",
|
applicationSecret: "",
|
||||||
consumerKey: "",
|
consumerKey: "",
|
||||||
|
zone: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildConfig = (data: DnsProviderForm) => {
|
const buildConfig = (data: DnsProviderForm) => {
|
||||||
@@ -137,6 +139,7 @@ const buildConfig = (data: DnsProviderForm) => {
|
|||||||
applicationKey: data.applicationKey,
|
applicationKey: data.applicationKey,
|
||||||
applicationSecret: data.applicationSecret,
|
applicationSecret: data.applicationSecret,
|
||||||
consumerKey: data.consumerKey,
|
consumerKey: data.consumerKey,
|
||||||
|
zone: data.zone.trim() || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -207,6 +210,7 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
|||||||
applicationKey: provider.config.applicationKey,
|
applicationKey: provider.config.applicationKey,
|
||||||
applicationSecret: provider.config.applicationSecret,
|
applicationSecret: provider.config.applicationSecret,
|
||||||
consumerKey: provider.config.consumerKey,
|
consumerKey: provider.config.consumerKey,
|
||||||
|
zone: provider.config.zone ?? "",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} else if (!dnsProviderId) {
|
} else if (!dnsProviderId) {
|
||||||
@@ -545,6 +549,27 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="zone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Zone (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="example.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Restrict this provider to a single DNS zone: Dokploy
|
||||||
|
will only list and change records in it. Leave empty to
|
||||||
|
use every zone the credentials can access. When set, the{" "}
|
||||||
|
<code>GET /domain/zone</code> right is not needed. This
|
||||||
|
is enforced by Dokploy only, so also limit the OVH token
|
||||||
|
to that zone if you need a hard guarantee.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
{
|
{
|
||||||
apiUrl: apiUrl ?? "",
|
apiUrl: apiUrl ?? "",
|
||||||
apiKey: apiKey ?? "",
|
apiKey: apiKey ?? "",
|
||||||
|
...(aiId ? { aiId } : {}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!apiUrl && (isLocalOllama || !!apiKey),
|
enabled: !!apiUrl && (isLocalOllama || !!apiKey),
|
||||||
@@ -482,6 +483,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
apiUrl={apiUrl}
|
apiUrl={apiUrl}
|
||||||
apiKey={apiKey}
|
apiKey={apiKey}
|
||||||
model={form.watch("model")}
|
model={form.watch("model")}
|
||||||
|
aiId={aiId}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" isLoading={isPending}>
|
<Button type="submit" isLoading={isPending}>
|
||||||
{aiId ? "Update" : "Create"}
|
{aiId ? "Update" : "Create"}
|
||||||
@@ -498,10 +500,12 @@ function TestConnectionButton({
|
|||||||
apiUrl,
|
apiUrl,
|
||||||
apiKey,
|
apiKey,
|
||||||
model,
|
model,
|
||||||
|
aiId,
|
||||||
}: {
|
}: {
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
aiId?: string;
|
||||||
}) {
|
}) {
|
||||||
const { mutate, isPending } = api.ai.testConnection.useMutation({
|
const { mutate, isPending } = api.ai.testConnection.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -521,7 +525,9 @@ function TestConnectionButton({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={isDisabled || isPending}
|
disabled={isDisabled || isPending}
|
||||||
onClick={() => mutate({ apiUrl, apiKey, model })}
|
onClick={() =>
|
||||||
|
mutate({ apiUrl, apiKey, model, ...(aiId ? { aiId } : {}) })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
|||||||
+575
@@ -0,0 +1,575 @@
|
|||||||
|
import type {
|
||||||
|
TelemetryProviderType,
|
||||||
|
TelemetrySignal,
|
||||||
|
} from "@dokploy/server/services/logs-and-metrics/types";
|
||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { telemetryProviderIcons } from "@/components/icons/telemetry-provider-icons";
|
||||||
|
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 {
|
||||||
|
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 { Switch } from "@/components/ui/switch";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const TOP_LEVEL_KEYS = new Set(["endpoint", "apiKey", "apiSecret"]);
|
||||||
|
|
||||||
|
const SIGNAL_OPTIONS: Array<{
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
signals: TelemetrySignal[];
|
||||||
|
}> = [
|
||||||
|
{ value: "logs", label: "Logs", signals: ["logs"] },
|
||||||
|
{ value: "metrics", label: "Metrics", signals: ["metrics"] },
|
||||||
|
{
|
||||||
|
value: "logs,metrics",
|
||||||
|
label: "Logs & Metrics",
|
||||||
|
signals: ["logs", "metrics"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const providerForm = z.object({
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
providerType: z.string().min(1, "Provider is required"),
|
||||||
|
signals: z.string().min(1, "Select what this provider sends"),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
fields: z.record(z.string(), z.string().optional()),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ProviderForm = z.infer<typeof providerForm>;
|
||||||
|
|
||||||
|
const toSignals = (value: string) =>
|
||||||
|
SIGNAL_OPTIONS.find((option) => option.value === value)?.signals ?? [];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
telemetryProviderId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentsUsingProvider = (
|
||||||
|
targets:
|
||||||
|
| Array<{
|
||||||
|
name: string;
|
||||||
|
telemetryProviderIds: string[];
|
||||||
|
}>
|
||||||
|
| undefined,
|
||||||
|
telemetryProviderId: string,
|
||||||
|
) =>
|
||||||
|
(targets ?? [])
|
||||||
|
.filter((target) =>
|
||||||
|
target.telemetryProviderIds.includes(telemetryProviderId),
|
||||||
|
)
|
||||||
|
.map((target) => target.name)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
export const HandleTelemetryProvider = ({ telemetryProviderId }: Props) => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [initialFields, setInitialFields] = useState<Record<string, string>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const [clearedKeys, setClearedKeys] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
const { data: availableTypes } =
|
||||||
|
api.telemetryProvider.availableTypes.useQuery();
|
||||||
|
const { data: provider } = api.telemetryProvider.one.useQuery(
|
||||||
|
{ telemetryProviderId: telemetryProviderId || "" },
|
||||||
|
{ enabled: !!telemetryProviderId, refetchOnWindowFocus: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const createMutation = api.telemetryProvider.create.useMutation();
|
||||||
|
const updateMutation = api.telemetryProvider.update.useMutation();
|
||||||
|
const {
|
||||||
|
isPending: isSaving,
|
||||||
|
error,
|
||||||
|
isError,
|
||||||
|
} = telemetryProviderId ? updateMutation : createMutation;
|
||||||
|
const {
|
||||||
|
mutateAsync: testConnection,
|
||||||
|
isPending: isTestingRaw,
|
||||||
|
error: testError,
|
||||||
|
isError: testIsError,
|
||||||
|
} = api.telemetryProvider.testConnection.useMutation();
|
||||||
|
const {
|
||||||
|
mutateAsync: testConnectionById,
|
||||||
|
isPending: isTestingById,
|
||||||
|
error: testByIdError,
|
||||||
|
isError: testByIdIsError,
|
||||||
|
} = api.telemetryProvider.testConnectionById.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<ProviderForm>({
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
providerType: "",
|
||||||
|
signals: "",
|
||||||
|
enabled: true,
|
||||||
|
fields: {},
|
||||||
|
},
|
||||||
|
resolver: zodResolver(providerForm),
|
||||||
|
});
|
||||||
|
|
||||||
|
const providerType = form.watch("providerType");
|
||||||
|
const signalsValue = form.watch("signals");
|
||||||
|
const fieldValues = form.watch("fields");
|
||||||
|
const signals = toSignals(signalsValue);
|
||||||
|
|
||||||
|
const selectedType = availableTypes?.find((t) => t.type === providerType);
|
||||||
|
const signalOptions = SIGNAL_OPTIONS.filter((option) =>
|
||||||
|
option.signals.every((s) => selectedType?.signals.includes(s)),
|
||||||
|
);
|
||||||
|
const visibleFields = (selectedType?.credentialFields ?? []).filter(
|
||||||
|
(field) => !field.signal || signals.includes(field.signal),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (provider) {
|
||||||
|
const extraConfig = (provider.extraConfig ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
form.reset({
|
||||||
|
name: provider.name,
|
||||||
|
providerType: provider.providerType,
|
||||||
|
signals: provider.signals.join(","),
|
||||||
|
enabled: provider.enabled,
|
||||||
|
fields: extraConfig,
|
||||||
|
});
|
||||||
|
setInitialFields(extraConfig);
|
||||||
|
} else {
|
||||||
|
form.reset();
|
||||||
|
setInitialFields({});
|
||||||
|
}
|
||||||
|
setClearedKeys({});
|
||||||
|
}, [form, provider, isOpen]);
|
||||||
|
|
||||||
|
const buildPayload = () => {
|
||||||
|
const payload: {
|
||||||
|
name: string;
|
||||||
|
providerType: TelemetryProviderType;
|
||||||
|
signals: TelemetrySignal[];
|
||||||
|
enabled: boolean;
|
||||||
|
endpoint?: string | null;
|
||||||
|
apiKey?: string | null;
|
||||||
|
apiSecret?: string | null;
|
||||||
|
extraConfig?: Record<string, string>;
|
||||||
|
} = {
|
||||||
|
name: form.getValues("name"),
|
||||||
|
providerType: providerType as TelemetryProviderType,
|
||||||
|
signals,
|
||||||
|
enabled: form.getValues("enabled"),
|
||||||
|
};
|
||||||
|
const extraConfig: Record<string, string> = {};
|
||||||
|
let hasExtraConfigFields = false;
|
||||||
|
for (const field of visibleFields) {
|
||||||
|
const value = fieldValues[field.key] ?? "";
|
||||||
|
if (
|
||||||
|
field.key === "endpoint" ||
|
||||||
|
field.key === "apiKey" ||
|
||||||
|
field.key === "apiSecret"
|
||||||
|
) {
|
||||||
|
if (value) {
|
||||||
|
payload[field.key] = value;
|
||||||
|
} else if (clearedKeys[field.key]) {
|
||||||
|
payload[field.key] = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hasExtraConfigFields = true;
|
||||||
|
extraConfig[field.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasExtraConfigFields) {
|
||||||
|
payload.extraConfig = extraConfig;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Required fields are per type and signal, so zod can't know them up front.
|
||||||
|
const validateRequiredFields = () => {
|
||||||
|
let valid = true;
|
||||||
|
for (const field of visibleFields) {
|
||||||
|
const value = fieldValues[field.key] ?? "";
|
||||||
|
const keepsStored =
|
||||||
|
!!telemetryProviderId &&
|
||||||
|
TOP_LEVEL_KEYS.has(field.key) &&
|
||||||
|
!clearedKeys[field.key];
|
||||||
|
if (field.required && !value && !keepsStored) {
|
||||||
|
form.setError(`fields.${field.key}`, {
|
||||||
|
message: `${field.label} is required`,
|
||||||
|
});
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return valid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const topLevelKeys = visibleFields
|
||||||
|
.filter((field) => TOP_LEVEL_KEYS.has(field.key))
|
||||||
|
.map((field) => field.key);
|
||||||
|
const touchedTopLevelKeys = topLevelKeys.filter(
|
||||||
|
(key) => !!fieldValues[key] || clearedKeys[key],
|
||||||
|
);
|
||||||
|
const touchedExtraConfigKeys = visibleFields
|
||||||
|
.filter((field) => !TOP_LEVEL_KEYS.has(field.key))
|
||||||
|
.map((field) => field.key)
|
||||||
|
.filter((key) => (fieldValues[key] ?? "") !== (initialFields[key] ?? ""));
|
||||||
|
const touchedAnyCredentialField =
|
||||||
|
touchedTopLevelKeys.length > 0 || touchedExtraConfigKeys.length > 0;
|
||||||
|
const isPartiallyTouched =
|
||||||
|
!!telemetryProviderId &&
|
||||||
|
touchedAnyCredentialField &&
|
||||||
|
touchedTopLevelKeys.length < topLevelKeys.length;
|
||||||
|
const signalsChanged =
|
||||||
|
!!provider && signalsValue !== provider.signals.join(",");
|
||||||
|
|
||||||
|
const showTestResult = (result: { warning?: string }) => {
|
||||||
|
if (result.warning) {
|
||||||
|
toast.message(result.warning);
|
||||||
|
} else {
|
||||||
|
toast.success("Connection tested successfully");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTest = async () => {
|
||||||
|
if (!(await form.trigger(["providerType", "signals"]))) return;
|
||||||
|
if (telemetryProviderId && !touchedAnyCredentialField) {
|
||||||
|
await testConnectionById({
|
||||||
|
telemetryProviderId,
|
||||||
|
...(signalsChanged ? { signals } : {}),
|
||||||
|
})
|
||||||
|
.then(showTestResult)
|
||||||
|
.catch((e) => {
|
||||||
|
toast.error(
|
||||||
|
e instanceof Error ? e.message : "Connection test failed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!validateRequiredFields()) return;
|
||||||
|
await testConnection(buildPayload())
|
||||||
|
.then(showTestResult)
|
||||||
|
.catch((e) => {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Connection test failed");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
if (!validateRequiredFields()) return;
|
||||||
|
const payload = buildPayload();
|
||||||
|
let warning: string | undefined;
|
||||||
|
let reapplied = false;
|
||||||
|
try {
|
||||||
|
if (telemetryProviderId) {
|
||||||
|
const result = await updateMutation.mutateAsync({
|
||||||
|
telemetryProviderId,
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
warning = result.warning;
|
||||||
|
reapplied = result.reapplied;
|
||||||
|
} else {
|
||||||
|
await createMutation.mutateAsync(payload);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(
|
||||||
|
telemetryProviderId
|
||||||
|
? "Error updating provider"
|
||||||
|
: "Error adding provider",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
utils.telemetryProvider.all.invalidate();
|
||||||
|
utils.telemetryProvider.serverStatus.invalidate();
|
||||||
|
if (telemetryProviderId) {
|
||||||
|
utils.telemetryProvider.one.invalidate({ telemetryProviderId });
|
||||||
|
}
|
||||||
|
const agents =
|
||||||
|
telemetryProviderId &&
|
||||||
|
agentsUsingProvider(
|
||||||
|
utils.telemetryProvider.serverStatus.getData(),
|
||||||
|
telemetryProviderId,
|
||||||
|
);
|
||||||
|
if (warning) {
|
||||||
|
toast.warning("Provider updated", { description: warning });
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
telemetryProviderId ? "Provider updated" : "Provider added",
|
||||||
|
reapplied && agents
|
||||||
|
? { description: `The Vector agent on ${agents} was re-applied.` }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTesting = isTestingRaw || isTestingById;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{telemetryProviderId ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-blue-500/10 "
|
||||||
|
>
|
||||||
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button className="cursor-pointer space-x-3">
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
Add provider
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{telemetryProviderId ? "Edit provider" : "Add a provider"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Vector ships container logs and host/container metrics from every
|
||||||
|
server you deploy it on to this provider.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{(isError || testIsError || testByIdIsError) && (
|
||||||
|
<AlertBlock type="error" className="w-full">
|
||||||
|
{testError?.message || testByIdError?.message || error?.message}
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="hook-form-telemetry-provider"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="grid w-full gap-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="e.g. Production Loki" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="providerType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Provider</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
form.setValue("signals", "");
|
||||||
|
form.setValue("fields", {});
|
||||||
|
form.clearErrors();
|
||||||
|
}}
|
||||||
|
disabled={!!telemetryProviderId}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a provider" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{availableTypes?.map((type) => {
|
||||||
|
const ProviderIcon =
|
||||||
|
telemetryProviderIcons[
|
||||||
|
type.type as keyof typeof telemetryProviderIcons
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<SelectItem key={type.type} value={type.type}>
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
{ProviderIcon && (
|
||||||
|
<ProviderIcon className="size-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
{type.label}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selectedType && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="signals"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Sends</FormLabel>
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select what this provider sends" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{signalOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibleFields.map((credentialField) => (
|
||||||
|
<FormField
|
||||||
|
key={credentialField.key}
|
||||||
|
control={form.control}
|
||||||
|
name={`fields.${credentialField.key}`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{credentialField.label}
|
||||||
|
{!credentialField.required && " (Optional)"}
|
||||||
|
</FormLabel>
|
||||||
|
{credentialField.helpText && (
|
||||||
|
<FormDescription>
|
||||||
|
{credentialField.helpText}
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type={
|
||||||
|
credentialField.type === "password"
|
||||||
|
? "password"
|
||||||
|
: "text"
|
||||||
|
}
|
||||||
|
disabled={!!clearedKeys[credentialField.key]}
|
||||||
|
placeholder={
|
||||||
|
clearedKeys[credentialField.key]
|
||||||
|
? "Will be cleared on update"
|
||||||
|
: telemetryProviderId &&
|
||||||
|
TOP_LEVEL_KEYS.has(credentialField.key)
|
||||||
|
? "Leave blank to keep existing"
|
||||||
|
: credentialField.placeholder
|
||||||
|
}
|
||||||
|
autoComplete={
|
||||||
|
credentialField.type === "password"
|
||||||
|
? "new-password"
|
||||||
|
: "off"
|
||||||
|
}
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{telemetryProviderId &&
|
||||||
|
TOP_LEVEL_KEYS.has(credentialField.key) &&
|
||||||
|
!credentialField.required && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setClearedKeys((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[credentialField.key]:
|
||||||
|
!prev[credentialField.key],
|
||||||
|
}));
|
||||||
|
field.onChange("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{clearedKeys[credentialField.key]
|
||||||
|
? "Keep"
|
||||||
|
: "Clear"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel>Enabled</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<DialogFooter className="flex flex-col w-full sm:justify-between gap-4 flex-wrap sm:flex-col">
|
||||||
|
{isPartiallyTouched && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
You changed something that needs a fresh credential to test — fill
|
||||||
|
in every credential field to test with the new values, or revert
|
||||||
|
it to test with what's already saved.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-row gap-2 justify-between">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
isLoading={isTesting}
|
||||||
|
disabled={!providerType || isPartiallyTouched}
|
||||||
|
onClick={onTest}
|
||||||
|
>
|
||||||
|
Test connection
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="hook-form-telemetry-provider"
|
||||||
|
isLoading={isSaving}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{telemetryProviderId ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import { Copy, Loader2, Server } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { telemetryProviderIcons } from "@/components/icons/telemetry-provider-icons";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
type Status = "running" | "not-running" | "stopped" | "unknown";
|
||||||
|
|
||||||
|
const statusBadge = (status: Status) => {
|
||||||
|
if (status === "running") {
|
||||||
|
return <Badge variant="green">Running</Badge>;
|
||||||
|
}
|
||||||
|
if (status === "not-running") {
|
||||||
|
return <Badge variant="yellow">Not running</Badge>;
|
||||||
|
}
|
||||||
|
if (status === "stopped") {
|
||||||
|
return <Badge variant="secondary">Not deployed</Badge>;
|
||||||
|
}
|
||||||
|
return <Badge variant="destructive">Unreachable</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const providerLabel = (provider: { name: string; signals: string[] }) =>
|
||||||
|
`${provider.name} (${provider.signals.length === 2 ? "logs and metrics" : provider.signals[0]})`;
|
||||||
|
|
||||||
|
export const ShowServers = () => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [selection, setSelection] = useState<Record<string, string[]>>({});
|
||||||
|
const [pendingTarget, setPendingTarget] = useState<string | null>(null);
|
||||||
|
const [errors, setErrors] = useState<
|
||||||
|
Record<string, { title: string; message: string }>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const { data: providers } = api.telemetryProvider.all.useQuery();
|
||||||
|
const { data: targets, isPending } =
|
||||||
|
api.telemetryProvider.serverStatus.useQuery(undefined, {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 30_000,
|
||||||
|
trpc: { context: { skipBatch: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: deployOnServer } =
|
||||||
|
api.telemetryProvider.deployOnServer.useMutation();
|
||||||
|
const { mutateAsync: removeOnServer } =
|
||||||
|
api.telemetryProvider.removeOnServer.useMutation();
|
||||||
|
|
||||||
|
const keyOf = (serverId: string | null) => serverId ?? "local";
|
||||||
|
|
||||||
|
// The selection can still hold providers deleted since it was made.
|
||||||
|
const idsFor = (serverId: string | null, saved: string[]) => {
|
||||||
|
const ids = selection[keyOf(serverId)] ?? saved;
|
||||||
|
return providers
|
||||||
|
? ids.filter((id) => providers.some((p) => p.telemetryProviderId === id))
|
||||||
|
: ids;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleProvider = (
|
||||||
|
serverId: string | null,
|
||||||
|
saved: string[],
|
||||||
|
telemetryProviderId: string,
|
||||||
|
) => {
|
||||||
|
const key = keyOf(serverId);
|
||||||
|
const current = idsFor(serverId, saved);
|
||||||
|
setSelection({
|
||||||
|
...selection,
|
||||||
|
[key]: current.includes(telemetryProviderId)
|
||||||
|
? current.filter((id) => id !== telemetryProviderId)
|
||||||
|
: [...current, telemetryProviderId],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setError = (
|
||||||
|
serverId: string | null,
|
||||||
|
error?: { title: string; message: string },
|
||||||
|
) =>
|
||||||
|
setErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if (error) {
|
||||||
|
next[keyOf(serverId)] = error;
|
||||||
|
} else {
|
||||||
|
delete next[keyOf(serverId)];
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The task is still starting right after a deploy, so look again once it had time to come up.
|
||||||
|
const refreshStatus = () => {
|
||||||
|
utils.telemetryProvider.serverStatus.invalidate();
|
||||||
|
setTimeout(() => utils.telemetryProvider.serverStatus.invalidate(), 8_000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeploy = async (serverId: string | null, ids: string[]) => {
|
||||||
|
setPendingTarget(keyOf(serverId));
|
||||||
|
setError(serverId);
|
||||||
|
await deployOnServer({ serverId, providerIds: ids })
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Vector agent deployed");
|
||||||
|
refreshStatus();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setError(serverId, {
|
||||||
|
title: "Deploy failed",
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to deploy the agent",
|
||||||
|
});
|
||||||
|
refreshStatus();
|
||||||
|
})
|
||||||
|
.finally(() => setPendingTarget(null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async (serverId: string | null) => {
|
||||||
|
setPendingTarget(keyOf(serverId));
|
||||||
|
setError(serverId);
|
||||||
|
await removeOnServer({ serverId })
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Vector agent removed");
|
||||||
|
setSelection((prev) => ({ ...prev, [keyOf(serverId)]: [] }));
|
||||||
|
refreshStatus();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setError(serverId, {
|
||||||
|
title: "Remove failed",
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to remove the agent",
|
||||||
|
});
|
||||||
|
refreshStatus();
|
||||||
|
})
|
||||||
|
.finally(() => setPendingTarget(null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasProviders = (providers?.length ?? 0) > 0;
|
||||||
|
const visibleTargets = hasProviders
|
||||||
|
? targets
|
||||||
|
: targets?.filter(
|
||||||
|
(target) =>
|
||||||
|
target.telemetryProviderIds.length > 0 ||
|
||||||
|
target.status === "running" ||
|
||||||
|
target.status === "not-running",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||||
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
|
<Server className="size-6 text-muted-foreground self-center" />
|
||||||
|
Servers
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Pick which providers each server ships to, then deploy its Vector
|
||||||
|
agent; a provider receives every signal it sends, and cAdvisor
|
||||||
|
runs next to the agent on 127.0.0.1:4510 when metrics are shipped.
|
||||||
|
Services deployed before Logs & Metrics need a redeploy to get the
|
||||||
|
labels it uses; until then their logs and container metrics are
|
||||||
|
not shipped from the Dokploy server.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 py-8 border-t">
|
||||||
|
{isPending ? (
|
||||||
|
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[15vh]">
|
||||||
|
<span>Checking servers...</span>
|
||||||
|
<Loader2 className="animate-spin size-4" />
|
||||||
|
</div>
|
||||||
|
) : !hasProviders && !visibleTargets?.length ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
||||||
|
<Server className="size-8 self-center text-muted-foreground" />
|
||||||
|
<span className="text-base text-muted-foreground text-center">
|
||||||
|
Add a provider first — there's nowhere to ship to yet
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{visibleTargets?.map((target) => {
|
||||||
|
const key = keyOf(target.serverId);
|
||||||
|
const saved = target.telemetryProviderIds;
|
||||||
|
const ids = idsFor(target.serverId, saved);
|
||||||
|
const isBusy = pendingTarget === key;
|
||||||
|
const agentDeployed = target.status !== "stopped";
|
||||||
|
const hasEnabledSelected = ids.some(
|
||||||
|
(id) =>
|
||||||
|
providers?.find((p) => p.telemetryProviderId === id)
|
||||||
|
?.enabled,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex flex-col gap-3 rounded-lg border p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{target.name}
|
||||||
|
</span>
|
||||||
|
{target.ipAddress && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{target.ipAddress}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{statusBadge(target.status as Status)}
|
||||||
|
{agentDeployed && (
|
||||||
|
<DialogAction
|
||||||
|
title="Remove the Vector agent"
|
||||||
|
description="This server stops shipping logs and metrics until you deploy it again. Continue?"
|
||||||
|
type="destructive"
|
||||||
|
onClick={() => handleRemove(target.serverId)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
isLoading={isBusy}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{hasProviders && (
|
||||||
|
<span className="text-xs font-medium">Providers</span>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{providers?.map((provider) => {
|
||||||
|
const ProviderIcon =
|
||||||
|
telemetryProviderIcons[
|
||||||
|
provider.providerType as keyof typeof telemetryProviderIcons
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={provider.telemetryProviderId}
|
||||||
|
className={`flex flex-row items-center gap-2 text-sm ${
|
||||||
|
provider.enabled ? "" : "opacity-60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`${key}-${provider.telemetryProviderId}`}
|
||||||
|
disabled={
|
||||||
|
!provider.enabled &&
|
||||||
|
!ids.includes(provider.telemetryProviderId)
|
||||||
|
}
|
||||||
|
checked={ids.includes(
|
||||||
|
provider.telemetryProviderId,
|
||||||
|
)}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
toggleProvider(
|
||||||
|
target.serverId,
|
||||||
|
saved,
|
||||||
|
provider.telemetryProviderId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{ProviderIcon && (
|
||||||
|
<ProviderIcon className="size-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
<Label
|
||||||
|
htmlFor={`${key}-${provider.telemetryProviderId}`}
|
||||||
|
className={`font-normal ${
|
||||||
|
provider.enabled
|
||||||
|
? "cursor-pointer"
|
||||||
|
: "cursor-not-allowed"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{providerLabel(provider)}
|
||||||
|
</Label>
|
||||||
|
{!provider.enabled && (
|
||||||
|
<Badge variant="secondary">Disabled</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errors[key] && (
|
||||||
|
<AlertBlock type="error" className="items-start">
|
||||||
|
<div className="flex w-full flex-col gap-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
{errors[key].title}
|
||||||
|
</span>
|
||||||
|
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-md bg-background/50 p-2 font-mono text-xs">
|
||||||
|
{errors[key].message}
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-fit"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
errors[key]?.message ?? "",
|
||||||
|
);
|
||||||
|
toast.success("Error copied");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="size-3.5" />
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasProviders && (
|
||||||
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
{ids.length > 0 && !hasEnabledSelected && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Every selected provider is disabled — nothing
|
||||||
|
would ship.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
isLoading={isBusy}
|
||||||
|
disabled={!hasEnabledSelected}
|
||||||
|
onClick={() => handleDeploy(target.serverId, ids)}
|
||||||
|
>
|
||||||
|
{agentDeployed || saved.length > 0
|
||||||
|
? "Redeploy"
|
||||||
|
: "Deploy"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{visibleTargets?.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
||||||
|
<Server className="size-8 self-center text-muted-foreground" />
|
||||||
|
<span className="text-base text-muted-foreground text-center">
|
||||||
|
You don't have any server yet
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
import { Loader2, ScrollText, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { telemetryProviderIcons } from "@/components/icons/telemetry-provider-icons";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { HandleTelemetryProvider } from "./handle-telemetry-provider";
|
||||||
|
|
||||||
|
interface TelemetryProviderRowProps {
|
||||||
|
provider: {
|
||||||
|
telemetryProviderId: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
providerType: string;
|
||||||
|
signals: string[];
|
||||||
|
};
|
||||||
|
index: number;
|
||||||
|
typeLabel: string;
|
||||||
|
canEdit: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TelemetryProviderRow = ({
|
||||||
|
provider,
|
||||||
|
index,
|
||||||
|
typeLabel,
|
||||||
|
canEdit,
|
||||||
|
canDelete,
|
||||||
|
onDeleted,
|
||||||
|
}: TelemetryProviderRowProps) => {
|
||||||
|
const { mutateAsync, isPending: isRemoving } =
|
||||||
|
api.telemetryProvider.remove.useMutation();
|
||||||
|
const ProviderIcon =
|
||||||
|
telemetryProviderIcons[
|
||||||
|
provider.providerType as keyof typeof telemetryProviderIcons
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg">
|
||||||
|
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||||
|
<div className="flex flex-row items-center gap-3">
|
||||||
|
{ProviderIcon && <ProviderIcon className="size-7 shrink-0" />}
|
||||||
|
<div className="flex gap-2 flex-col">
|
||||||
|
<span className="text-sm font-medium flex items-center gap-2">
|
||||||
|
{index + 1}. {provider.name}
|
||||||
|
<Badge variant={provider.enabled ? "green" : "secondary"}>
|
||||||
|
{provider.enabled ? "Enabled" : "Disabled"}
|
||||||
|
</Badge>
|
||||||
|
{provider.signals.includes("logs") && (
|
||||||
|
<Badge variant="blue">Logs</Badge>
|
||||||
|
)}
|
||||||
|
{provider.signals.includes("metrics") && (
|
||||||
|
<Badge variant="orange">Metrics</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="text-xs text-muted-foreground">{typeLabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row gap-1">
|
||||||
|
{canEdit && (
|
||||||
|
<HandleTelemetryProvider
|
||||||
|
telemetryProviderId={provider.telemetryProviderId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canDelete && (
|
||||||
|
<DialogAction
|
||||||
|
title="Delete provider"
|
||||||
|
description="Are you sure you want to delete this provider? Every agent shipping to it is re-applied without it, and removed if nothing is left."
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await mutateAsync({
|
||||||
|
telemetryProviderId: provider.telemetryProviderId,
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
if (result.warning) {
|
||||||
|
toast.warning("Provider deleted", {
|
||||||
|
description: result.warning,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
"Provider deleted — the agents that used it were re-applied",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
onDeleted();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deleting provider");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10 "
|
||||||
|
isLoading={isRemoving}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShowTelemetryProviders = () => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { data, isPending, refetch } = api.telemetryProvider.all.useQuery();
|
||||||
|
const { data: availableTypes } =
|
||||||
|
api.telemetryProvider.availableTypes.useQuery();
|
||||||
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
const providerListLabel = availableTypes?.length
|
||||||
|
? ` (${availableTypes.map((t) => t.label).join(", ")})`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||||
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
|
<CardHeader className="">
|
||||||
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
|
<ScrollText className="size-6 text-muted-foreground self-center" />
|
||||||
|
Logs & Metrics
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{`Ship container logs and host/container metrics to an external provider${providerListLabel}. Add one here, choose what it sends, then deploy the Vector agent on the servers below.`}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 py-8 border-t">
|
||||||
|
{isPending ? (
|
||||||
|
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
|
||||||
|
<span>Loading...</span>
|
||||||
|
<Loader2 className="animate-spin size-4" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{data?.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
|
<ScrollText className="size-8 self-center text-muted-foreground" />
|
||||||
|
<span className="text-base text-muted-foreground text-center">
|
||||||
|
You don't have any provider configured
|
||||||
|
</span>
|
||||||
|
{permissions?.telemetryProvider.create && (
|
||||||
|
<HandleTelemetryProvider />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||||
|
<div className="flex flex-col gap-4 rounded-lg ">
|
||||||
|
{data?.map((provider, index) => (
|
||||||
|
<TelemetryProviderRow
|
||||||
|
key={provider.telemetryProviderId}
|
||||||
|
provider={provider}
|
||||||
|
index={index}
|
||||||
|
typeLabel={
|
||||||
|
availableTypes?.find(
|
||||||
|
(t) => t.type === provider.providerType,
|
||||||
|
)?.label ?? provider.providerType
|
||||||
|
}
|
||||||
|
canEdit={!!permissions?.telemetryProvider.create}
|
||||||
|
canDelete={!!permissions?.telemetryProvider.delete}
|
||||||
|
onDeleted={() => {
|
||||||
|
refetch();
|
||||||
|
utils.telemetryProvider.serverStatus.invalidate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{permissions?.telemetryProvider.create && (
|
||||||
|
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||||
|
<HandleTelemetryProvider />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { Loader2, Trash2 } from "lucide-react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const errorMessage = (error: unknown, fallback: string) =>
|
||||||
|
error instanceof Error ? error.message : fallback;
|
||||||
|
|
||||||
|
export const DeleteAccount = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [step, setStep] = useState<"request" | "confirm">("request");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [expiresInMinutes, setExpiresInMinutes] = useState(10);
|
||||||
|
const { data } = api.user.get.useQuery();
|
||||||
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: hasPassword } = api.user.hasPassword.useQuery();
|
||||||
|
const requestCode = api.user.requestAccountDeletionCode.useMutation();
|
||||||
|
const deleteAccount = api.user.deleteAccount.useMutation();
|
||||||
|
|
||||||
|
const email = data?.user?.email ?? "";
|
||||||
|
const isBusy = requestCode.isPending || deleteAccount.isPending;
|
||||||
|
|
||||||
|
if (!isCloud || !data || data.role !== "owner" || hasPassword === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setStep("request");
|
||||||
|
setPassword("");
|
||||||
|
setCode("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRequestCode = async () => {
|
||||||
|
try {
|
||||||
|
const result = await requestCode.mutateAsync(
|
||||||
|
hasPassword ? { password } : {},
|
||||||
|
);
|
||||||
|
setExpiresInMinutes(result.expiresInMinutes);
|
||||||
|
setCode("");
|
||||||
|
setStep("confirm");
|
||||||
|
toast.success(`We sent a confirmation code to ${email}`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(errorMessage(error, "Error sending the confirmation code"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await deleteAccount.mutateAsync({ code });
|
||||||
|
toast.success("Your account has been deleted");
|
||||||
|
await authClient.signOut().catch(() => undefined);
|
||||||
|
router.push("/");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(errorMessage(error, "Error deleting account"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-transparent border-destructive/40">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl flex items-center gap-2 text-destructive">
|
||||||
|
<Trash2 className="size-5" />
|
||||||
|
Delete account
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Permanently remove your account and all the data associated with it.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<ul className="text-sm text-muted-foreground list-disc pl-5 space-y-1">
|
||||||
|
<li>
|
||||||
|
Active subscriptions are cancelled immediately. Invoices already
|
||||||
|
issued are kept for accounting.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Every organization you own is deleted, including its projects,
|
||||||
|
services, environments, domains and server connections.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
In organizations you belong to but do not own, only your membership,
|
||||||
|
API keys and the Git providers you connected are removed.
|
||||||
|
Applications using those providers keep running but must be
|
||||||
|
reconnected to redeploy.
|
||||||
|
</li>
|
||||||
|
<li>Containers already running on your servers are not touched.</li>
|
||||||
|
<li>
|
||||||
|
We will email a confirmation code to{" "}
|
||||||
|
<span className="font-medium text-foreground">{email}</span> before
|
||||||
|
anything is deleted.
|
||||||
|
</li>
|
||||||
|
<li>This action cannot be undone.</li>
|
||||||
|
</ul>
|
||||||
|
<div>
|
||||||
|
<AlertDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (!next) reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive">Delete account</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete your account?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{step === "request"
|
||||||
|
? `This will permanently delete your account and every organization you own. We will send a confirmation code to ${email}.`
|
||||||
|
: `Enter the ${expiresInMinutes}-minute code we sent to ${email} to confirm the deletion.`}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{step === "request" && hasPassword && (
|
||||||
|
<>
|
||||||
|
<Label htmlFor="delete-account-password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="delete-account-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === "confirm" && (
|
||||||
|
<>
|
||||||
|
<Label htmlFor="delete-account-code">
|
||||||
|
Confirmation code
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="delete-account-code"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="123456"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCode(e.target.value.replace(/\D/g, ""))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
className="self-start px-0"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={handleRequestCode}
|
||||||
|
>
|
||||||
|
Resend code
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isBusy}>Cancel</AlertDialogCancel>
|
||||||
|
{step === "request" ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isBusy || (hasPassword && password.length === 0)}
|
||||||
|
onClick={handleRequestCode}
|
||||||
|
>
|
||||||
|
{requestCode.isPending && (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Send confirmation code
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isBusy || code.length !== 6}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
{deleteAccount.isPending && (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Delete account
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
+18
-20
@@ -1,6 +1,4 @@
|
|||||||
import { Activity } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -15,31 +13,31 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
asButton?: boolean;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShowServerActions = ({ serverId, asButton = false }: Props) => {
|
export const ShowServerActions = ({ serverId, children }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
{asButton ? (
|
{children ? (
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
|
||||||
<Activity className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem
|
<DialogTrigger asChild>
|
||||||
className="w-full cursor-pointer"
|
<DropdownMenuItem
|
||||||
onSelect={(e) => {
|
className="w-full cursor-pointer"
|
||||||
e.preventDefault();
|
onSelect={(e) => {
|
||||||
setIsOpen(true);
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
View Actions
|
View Actions
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
)}
|
)}
|
||||||
<DialogContent className="sm:max-w-xl">
|
<DialogContent
|
||||||
|
className="sm:max-w-xl"
|
||||||
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<DialogTitle className="text-xl">Web server settings</DialogTitle>
|
<DialogTitle className="text-xl">Web server settings</DialogTitle>
|
||||||
<DialogDescription>Reload or clean the web server.</DialogDescription>
|
<DialogDescription>Reload or clean the web server.</DialogDescription>
|
||||||
|
|||||||
@@ -109,8 +109,13 @@ export const DeleteServerModal = ({
|
|||||||
|
|
||||||
const handleDeleteServer = async () => {
|
const handleDeleteServer = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteServer({ serverId });
|
const result = await deleteServer({ serverId });
|
||||||
toast.success(`Server ${serverName} deleted successfully`);
|
toast.success(`Server ${serverName} deleted successfully`);
|
||||||
|
if (result?.vectorAgentRemovalWarning) {
|
||||||
|
toast.error(
|
||||||
|
`Couldn't reach this host to remove the Vector logs and metrics agent; if it was deployed it may still be running: ${result.vectorAgentRemovalWarning}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
utils.server.all.invalidate();
|
utils.server.all.invalidate();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -121,7 +126,10 @@ export const DeleteServerModal = ({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent
|
||||||
|
className="max-w-lg"
|
||||||
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete Server</DialogTitle>
|
<DialogTitle>Delete Server</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { Pencil, PlusIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -61,10 +61,10 @@ type Schema = z.infer<typeof Schema>;
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
asButton?: boolean;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
export const HandleServers = ({ serverId, children }: Props) => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { data: canCreateMoreServers, refetch } =
|
const { data: canCreateMoreServers, refetch } =
|
||||||
@@ -141,24 +141,19 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
{serverId ? (
|
{children ? (
|
||||||
asButton ? (
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
<DialogTrigger asChild>
|
) : serverId ? (
|
||||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
<DialogTrigger asChild>
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
) : (
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer "
|
className="w-full cursor-pointer "
|
||||||
onSelect={(e) => {
|
onSelect={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsOpen(true);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Edit Server
|
Edit Server
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)
|
</DialogTrigger>
|
||||||
) : (
|
) : (
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="cursor-pointer space-x-3">
|
<Button className="cursor-pointer space-x-3">
|
||||||
@@ -167,7 +162,10 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
)}
|
)}
|
||||||
<DialogContent className="sm:max-w-3xl ">
|
<DialogContent
|
||||||
|
className="sm:max-w-3xl "
|
||||||
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
|
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ import { ValidateServer } from "./validate-server";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
asButton?: boolean;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
export const SetupServer = ({ serverId, children }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { data: server } = api.server.one.useQuery(
|
const { data: server } = api.server.one.useQuery(
|
||||||
{
|
{
|
||||||
@@ -81,24 +81,19 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
{asButton ? (
|
{children ? (
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
) : (
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
<Button className="w-full cursor-pointer " size="sm">
|
||||||
<Settings className="h-4 w-4" />
|
Setup Server <Settings className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
className="w-full cursor-pointer "
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setIsOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Setup Server <Settings className="size-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
<DialogContent className="sm:max-w-4xl ">
|
<DialogContent
|
||||||
|
className="sm:max-w-4xl "
|
||||||
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -7,19 +7,27 @@ import { ShowPaidMonitoring } from "../../monitoring/paid/servers/show-paid-moni
|
|||||||
interface Props {
|
interface Props {
|
||||||
url: string;
|
url: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShowMonitoringModal = ({ url, token }: Props) => {
|
export const ShowMonitoringModal = ({ url, token, children }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
{children ? (
|
||||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
<BarChartHorizontalBigIcon className="h-4 w-4" />
|
) : (
|
||||||
</Button>
|
<DialogTrigger asChild>
|
||||||
</DialogTrigger>
|
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||||
<DialogContent className="sm:max-w-7xl ">
|
<BarChartHorizontalBigIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
)}
|
||||||
|
<DialogContent
|
||||||
|
className="sm:max-w-7xl "
|
||||||
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
<div className="flex gap-4 py-4 w-full">
|
<div className="flex gap-4 py-4 w-full">
|
||||||
<ShowPaidMonitoring BASE_URL={url} token={token} />
|
<ShowPaidMonitoring BASE_URL={url} token={token} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import {
|
import {
|
||||||
|
Activity,
|
||||||
|
BarChartHorizontalBigIcon,
|
||||||
Clock,
|
Clock,
|
||||||
Key,
|
Key,
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
Network,
|
Network,
|
||||||
|
Pencil,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
|
Settings,
|
||||||
Terminal,
|
Terminal,
|
||||||
Trash2,
|
Trash2,
|
||||||
User,
|
User,
|
||||||
@@ -224,11 +228,19 @@ export const ShowServers = () => {
|
|||||||
<div className="flex items-center gap-2 pt-3 border-t mt-auto flex-wrap">
|
<div className="flex items-center gap-2 pt-3 border-t mt-auto flex-wrap">
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex items-center gap-2 w-full">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<SetupServer
|
||||||
<SetupServer
|
serverId={server.serverId}
|
||||||
serverId={server.serverId}
|
>
|
||||||
/>
|
<TooltipTrigger asChild>
|
||||||
</TooltipTrigger>
|
<Button
|
||||||
|
className="w-full cursor-pointer "
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Setup Server{" "}
|
||||||
|
<Settings className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
</SetupServer>
|
||||||
<TooltipContent
|
<TooltipContent
|
||||||
className="max-w-xs"
|
className="max-w-xs"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
@@ -251,22 +263,20 @@ export const ShowServers = () => {
|
|||||||
{server.sshKeyId &&
|
{server.sshKeyId &&
|
||||||
permissions?.server.terminal && (
|
permissions?.server.terminal && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TerminalModal
|
||||||
<div>
|
serverId={server.serverId}
|
||||||
<TerminalModal
|
asButton={true}
|
||||||
serverId={server.serverId}
|
>
|
||||||
asButton={true}
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
>
|
>
|
||||||
<Button
|
<Terminal className="h-4 w-4" />
|
||||||
variant="outline"
|
</Button>
|
||||||
size="icon"
|
</TooltipTrigger>
|
||||||
className="h-9 w-9"
|
</TerminalModal>
|
||||||
>
|
|
||||||
<Terminal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TerminalModal>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Terminal</p>
|
<p>Terminal</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
@@ -274,14 +284,19 @@ export const ShowServers = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<HandleServers
|
||||||
<div>
|
serverId={server.serverId}
|
||||||
<HandleServers
|
>
|
||||||
serverId={server.serverId}
|
<TooltipTrigger asChild>
|
||||||
asButton={true}
|
<Button
|
||||||
/>
|
variant="outline"
|
||||||
</div>
|
size="icon"
|
||||||
</TooltipTrigger>
|
className="h-9 w-9"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
</HandleServers>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Edit Server</p>
|
<p>Edit Server</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
@@ -289,14 +304,19 @@ export const ShowServers = () => {
|
|||||||
|
|
||||||
{server.sshKeyId && !isBuildServer && (
|
{server.sshKeyId && !isBuildServer && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<ShowServerActions
|
||||||
<div>
|
serverId={server.serverId}
|
||||||
<ShowServerActions
|
>
|
||||||
serverId={server.serverId}
|
<TooltipTrigger asChild>
|
||||||
asButton={true}
|
<Button
|
||||||
/>
|
variant="outline"
|
||||||
</div>
|
size="icon"
|
||||||
</TooltipTrigger>
|
className="h-9 w-9"
|
||||||
|
>
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
</ShowServerActions>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Web Server Actions</p>
|
<p>Web Server Actions</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
@@ -307,17 +327,23 @@ export const ShowServers = () => {
|
|||||||
server.sshKeyId &&
|
server.sshKeyId &&
|
||||||
!isBuildServer && (
|
!isBuildServer && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<ShowMonitoringModal
|
||||||
<div>
|
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
|
||||||
<ShowMonitoringModal
|
token={
|
||||||
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
|
server?.metricsConfig?.server
|
||||||
token={
|
?.token
|
||||||
server?.metricsConfig
|
}
|
||||||
?.server?.token
|
>
|
||||||
}
|
<TooltipTrigger asChild>
|
||||||
/>
|
<Button
|
||||||
</div>
|
variant="outline"
|
||||||
</TooltipTrigger>
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
>
|
||||||
|
<BarChartHorizontalBigIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
</ShowMonitoringModal>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Monitoring</p>
|
<p>Monitoring</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
@@ -328,22 +354,20 @@ export const ShowServers = () => {
|
|||||||
|
|
||||||
{permissions?.server.delete && (
|
{permissions?.server.delete && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<DeleteServerModal
|
||||||
<div>
|
serverId={server.serverId}
|
||||||
<DeleteServerModal
|
serverName={server.name}
|
||||||
serverId={server.serverId}
|
>
|
||||||
serverName={server.name}
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
>
|
>
|
||||||
<Button
|
<Trash2 className="h-4 w-4" />
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="icon"
|
</TooltipTrigger>
|
||||||
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
|
</DeleteServerModal>
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DeleteServerModal>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Delete Server</p>
|
<p>Delete Server</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
|
|||||||
@@ -124,8 +124,11 @@ export const AddInvitation = () => {
|
|||||||
const mode = form.watch("mode");
|
const mode = form.watch("mode");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset();
|
if (!open) {
|
||||||
}, [form, form.formState.isSubmitSuccessful, form.reset]);
|
form.reset();
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [open, form, form.reset]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCloud && form.getValues("mode") === "credentials") {
|
if (isCloud && form.getValues("mode") === "credentials") {
|
||||||
@@ -225,7 +228,7 @@ export const AddInvitation = () => {
|
|||||||
<FormLabel>Invite Method</FormLabel>
|
<FormLabel>Invite Method</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -313,7 +316,7 @@ export const AddInvitation = () => {
|
|||||||
<FormLabel>Email Provider</FormLabel>
|
<FormLabel>Email Provider</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
|||||||
@@ -45,9 +45,8 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
|||||||
term.loadAddon(clipboardAddon);
|
term.loadAddon(clipboardAddon);
|
||||||
fixMacOsAltKeys(term);
|
fixMacOsAltKeys(term);
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-expect-error
|
||||||
term.open(termRef.current);
|
term.open(termRef.current);
|
||||||
// @ts-ignore
|
|
||||||
term.loadAddon(addonFit);
|
term.loadAddon(addonFit);
|
||||||
addonFit.fit();
|
addonFit.fit();
|
||||||
|
|
||||||
@@ -89,9 +88,9 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
|||||||
}, [id, serverId]);
|
}, [id, serverId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4 h-full w-full">
|
||||||
<div className="w-full h-full bg-transparent border rounded-lg p-2">
|
<div className="w-full h-full bg-transparent border rounded-lg p-2">
|
||||||
<div id={id} ref={termRef} className="rounded-xl" />
|
<div id={id} ref={termRef} className="rounded-xl h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import type { ServiceType } from "@dokploy/server/db/schema";
|
||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { ArrowRightLeft } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { type LogLine, parseLogs } from "../docker/logs/utils";
|
||||||
|
|
||||||
|
const LOCAL_SERVER = "dokploy";
|
||||||
|
|
||||||
|
const transferSchema = z.object({
|
||||||
|
targetServerId: z.string().min(1, { message: "Select a target server" }),
|
||||||
|
removeSourceData: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TransferForm = z.infer<typeof transferSchema>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
type: ServiceType;
|
||||||
|
serverId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TransferService = ({ id, type, serverId }: Props) => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
|
const [isTransferring, setIsTransferring] = useState(false);
|
||||||
|
const [logs, setLogs] = useState<LogLine[]>([]);
|
||||||
|
const [request, setRequest] = useState<{
|
||||||
|
targetServerId: string | null;
|
||||||
|
removeSourceData: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const targets = [
|
||||||
|
...(!isCloud && serverId
|
||||||
|
? [{ serverId: LOCAL_SERVER, name: "Dokploy Server" }]
|
||||||
|
: []),
|
||||||
|
...(servers ?? []).filter((server) => server.serverId !== serverId),
|
||||||
|
];
|
||||||
|
|
||||||
|
const form = useForm<TransferForm>({
|
||||||
|
defaultValues: { targetServerId: "", removeSourceData: false },
|
||||||
|
resolver: zodResolver(transferSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
api.transfer.start.useSubscription(
|
||||||
|
{
|
||||||
|
serviceType: type,
|
||||||
|
serviceId: id,
|
||||||
|
targetServerId: request?.targetServerId ?? null,
|
||||||
|
removeSourceData: request?.removeSourceData ?? false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: isTransferring && request !== null,
|
||||||
|
onData(line) {
|
||||||
|
setLogs((prev) => [...prev, ...parseLogs(line)]);
|
||||||
|
if (line.startsWith("Transfer completed")) {
|
||||||
|
setIsTransferring(false);
|
||||||
|
toast.success("Service transferred successfully");
|
||||||
|
utils.invalidate();
|
||||||
|
} else if (line.startsWith("Transfer failed")) {
|
||||||
|
setIsTransferring(false);
|
||||||
|
toast.error("Transfer failed, check the logs");
|
||||||
|
utils.invalidate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
setIsTransferring(false);
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSubmit = (values: TransferForm) => {
|
||||||
|
setRequest({
|
||||||
|
targetServerId:
|
||||||
|
values.targetServerId === LOCAL_SERVER ? null : values.targetServerId,
|
||||||
|
removeSourceData: values.removeSourceData,
|
||||||
|
});
|
||||||
|
setLogs([]);
|
||||||
|
setIsDrawerOpen(true);
|
||||||
|
setIsTransferring(true);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-blue-500/10"
|
||||||
|
isLoading={isTransferring}
|
||||||
|
>
|
||||||
|
<ArrowRightLeft className="size-4 text-primary group-hover:text-blue-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Transfer to another server</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Moves this service with its volumes, bind mounts, file mounts and
|
||||||
|
configuration to the selected server, then deploys it there.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{targets.length === 0 ? (
|
||||||
|
<AlertBlock type="info">
|
||||||
|
There are no other servers available to transfer this service to.
|
||||||
|
</AlertBlock>
|
||||||
|
) : (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
id="hook-form-transfer-service"
|
||||||
|
className="grid w-full gap-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="targetServerId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Target server</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a server" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{targets.map((server) => (
|
||||||
|
<SelectItem
|
||||||
|
key={server.serverId}
|
||||||
|
value={server.serverId}
|
||||||
|
>
|
||||||
|
{server.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="removeSourceData"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="ml-2">
|
||||||
|
Remove volumes from the source server after the
|
||||||
|
transfer
|
||||||
|
</FormLabel>
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
<ul className="list-disc pl-4 space-y-1">
|
||||||
|
<li>The service is stopped while its data is copied.</li>
|
||||||
|
<li>
|
||||||
|
Point your DNS records to the new server, certificates are
|
||||||
|
issued again there.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Networks that only exist on the current server are
|
||||||
|
detached.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Bind mount host paths are copied but never deleted from
|
||||||
|
the source server.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</AlertBlock>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="secondary" onClick={() => setIsOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{targets.length > 0 && (
|
||||||
|
<Button
|
||||||
|
isLoading={isTransferring}
|
||||||
|
form="hook-form-transfer-service"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Transfer
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<DrawerLogs
|
||||||
|
isOpen={isDrawerOpen}
|
||||||
|
onClose={() => setIsDrawerOpen(false)}
|
||||||
|
filteredLogs={logs}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { AwsIcon } from "@/components/icons/vault-provider-icons";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GrafanaLokiIcon = ({ className }: Props) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="#F46800"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M23.02 10.59a8.578 8.578 0 0 0-.862-3.034 8.911 8.911 0 0 0-1.789-2.445c.337-1.342-.413-2.505-.413-2.505-1.292-.08-2.113.4-2.416.62-.052-.02-.102-.044-.154-.064-.22-.089-.446-.172-.677-.247-.231-.073-.47-.14-.711-.197a9.867 9.867 0 0 0-.875-.161C14.557.753 12.94 0 12.94 0c-1.804 1.145-2.147 2.744-2.147 2.744l-.018.093c-.098.029-.2.057-.298.088-.138.042-.275.094-.413.143-.138.055-.275.107-.41.166a8.869 8.869 0 0 0-1.557.87l-.063-.029c-2.497-.955-4.716.195-4.716.195-.203 2.658.996 4.33 1.235 4.636a11.608 11.608 0 0 0-.607 2.635C1.636 12.677.953 15.014.953 15.014c1.926 2.214 4.171 2.351 4.171 2.351.003-.002.006-.002.006-.005.285.509.615.994.986 1.446.156.19.32.371.488.548-.704 2.009.099 3.68.099 3.68 2.144.08 3.553-.937 3.849-1.173a9.784 9.784 0 0 0 3.164.501h.08l.055-.003.107-.002.103-.005.003.002c1.01 1.44 2.788 1.646 2.788 1.646 1.264-1.332 1.337-2.653 1.337-2.94v-.058c0-.02-.003-.039-.003-.06.265-.187.52-.387.758-.6a7.875 7.875 0 0 0 1.415-1.7c1.43.083 2.437-.885 2.437-.885-.236-1.49-1.085-2.216-1.264-2.354l-.018-.013-.016-.013a.217.217 0 0 1-.031-.02c.008-.092.016-.18.02-.27.011-.162.016-.323.016-.48v-.253l-.005-.098-.008-.135a1.891 1.891 0 0 0-.01-.13c-.003-.042-.008-.083-.013-.125l-.016-.124-.018-.122a6.215 6.215 0 0 0-2.032-3.73 6.015 6.015 0 0 0-3.222-1.46 6.292 6.292 0 0 0-.85-.048l-.107.002h-.063l-.044.003-.104.008a4.777 4.777 0 0 0-3.335 1.695c-.332.4-.592.84-.768 1.297a4.594 4.594 0 0 0-.312 1.817l.003.091c.005.055.007.11.013.164a3.615 3.615 0 0 0 .698 1.82 3.53 3.53 0 0 0 1.827 1.282c.33.098.66.14.971.137.039 0 .078 0 .114-.002l.063-.003c.02 0 .041-.003.062-.003.034-.002.065-.007.099-.01.007 0 .018-.003.028-.003l.031-.005.06-.008a1.18 1.18 0 0 0 .112-.02c.036-.008.072-.013.109-.024a2.634 2.634 0 0 0 .914-.415c.028-.02.056-.041.085-.065a.248.248 0 0 0 .039-.35.244.244 0 0 0-.309-.06l-.078.042c-.09.044-.184.083-.283.116a2.476 2.476 0 0 1-.475.096c-.028.003-.054.006-.083.006l-.083.002c-.026 0-.054 0-.08-.002l-.102-.006h-.012l-.024.006c-.016-.003-.031-.003-.044-.006-.031-.002-.06-.007-.091-.01a2.59 2.59 0 0 1-.724-.213 2.557 2.557 0 0 1-.667-.438 2.52 2.52 0 0 1-.805-1.475 2.306 2.306 0 0 1-.029-.444l.006-.122v-.023l.002-.031c.003-.021.003-.04.005-.06a3.163 3.163 0 0 1 1.352-2.29 3.12 3.12 0 0 1 .937-.43 2.946 2.946 0 0 1 .776-.101h.06l.07.002.045.003h.026l.07.005a4.041 4.041 0 0 1 1.635.49 3.94 3.94 0 0 1 1.602 1.662 3.77 3.77 0 0 1 .397 1.414l.005.076.003.075c.002.026.002.05.002.075 0 .024.003.052 0 .07v.065l-.002.073-.008.174a6.195 6.195 0 0 1-.08.639 5.1 5.1 0 0 1-.267.927 5.31 5.31 0 0 1-.624 1.13 5.052 5.052 0 0 1-3.237 2.014 4.82 4.82 0 0 1-.649.066l-.039.003h-.287a6.607 6.607 0 0 1-1.716-.265 6.776 6.776 0 0 1-3.4-2.274 6.75 6.75 0 0 1-.746-1.15 6.616 6.616 0 0 1-.714-2.596l-.005-.083-.002-.02v-.056l-.003-.073v-.096l-.003-.104v-.07l.003-.163c.008-.22.026-.45.054-.678a8.707 8.707 0 0 1 .28-1.355c.128-.444.286-.872.473-1.277a7.04 7.04 0 0 1 1.456-2.1 5.925 5.925 0 0 1 .953-.763c.169-.111.343-.213.524-.306.089-.05.182-.091.273-.135.047-.02.093-.042.138-.062a7.177 7.177 0 0 1 .714-.267l.145-.045c.049-.015.098-.026.148-.041.098-.029.197-.052.296-.076.049-.013.1-.02.15-.033l.15-.032.151-.028.076-.013.075-.01.153-.024c.057-.01.114-.013.171-.023l.169-.021c.036-.003.073-.008.106-.01l.073-.008.036-.003.042-.002c.057-.003.114-.008.171-.01l.086-.006h.023l.037-.003.145-.007a7.999 7.999 0 0 1 1.708.125 7.917 7.917 0 0 1 2.048.68 8.253 8.253 0 0 1 1.672 1.09l.09.077.089.078c.06.052.114.107.171.159.057.052.112.106.166.16.052.055.107.107.159.164a8.671 8.671 0 0 1 1.41 1.978c.012.026.028.052.04.078l.04.078.075.156c.023.051.05.1.07.153l.065.15a8.848 8.848 0 0 1 .45 1.34.19.19 0 0 0 .201.142.186.186 0 0 0 .172-.184c.01-.246.002-.532-.024-.856z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DatadogIcon = ({ className }: Props) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="#632CA6"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M19.57 17.04l-1.997-1.316-1.665 2.782-1.937-.567-1.706 2.604.087.82 9.274-1.71-.538-5.794zm-8.649-2.498l1.488-.204c.241.108.409.15.697.223.45.117.97.23 1.741-.16.18-.088.553-.43.704-.625l6.096-1.106.622 7.527-10.444 1.882zm11.325-2.712l-.602.115L20.488 0 .789 2.285l2.427 19.693 2.306-.334c-.184-.263-.471-.581-.96-.989-.68-.564-.44-1.522-.039-2.127.53-1.022 3.26-2.322 3.106-3.956-.056-.594-.15-1.368-.702-1.898-.02.22.017.432.017.432s-.227-.289-.34-.683c-.112-.15-.2-.199-.319-.4-.085.233-.073.503-.073.503s-.186-.437-.216-.807c-.11.166-.137.48-.137.48s-.241-.69-.186-1.062c-.11-.323-.436-.965-.343-2.424.6.421 1.924.321 2.44-.439.171-.251.288-.939-.086-2.293-.24-.868-.835-2.16-1.066-2.651l-.028.02c.122.395.374 1.223.47 1.625.293 1.218.372 1.642.234 2.204-.116.488-.397.808-1.107 1.165-.71.358-1.653-.514-1.713-.562-.69-.55-1.224-1.447-1.284-1.883-.062-.477.275-.763.445-1.153-.243.07-.514.192-.514.192s.323-.334.722-.624c.165-.109.262-.178.436-.323a9.762 9.762 0 0 0-.456.003s.42-.227.855-.392c-.318-.014-.623-.003-.623-.003s.937-.419 1.678-.727c.509-.208 1.006-.147 1.286.257.367.53.752.817 1.569.996.501-.223.653-.337 1.284-.509.554-.61.99-.688.99-.688s-.216.198-.274.51c.314-.249.66-.455.66-.455s-.134.164-.259.426l.03.043c.366-.22.797-.394.797-.394s-.123.156-.268.358c.277-.002.838.012 1.056.037 1.285.028 1.552-1.374 2.045-1.55.618-.22.894-.353 1.947.68.903.888 1.609 2.477 1.259 2.833-.294.295-.874-.115-1.516-.916a3.466 3.466 0 0 1-.716-1.562 1.533 1.533 0 0 0-.497-.85s.23.51.23.96c0 .246.03 1.165.424 1.68-.039.076-.057.374-.1.43-.458-.554-1.443-.95-1.604-1.067.544.445 1.793 1.468 2.273 2.449.453.927.186 1.777.416 1.997.065.063.976 1.197 1.15 1.767.306.994.019 2.038-.381 2.685l-1.117.174c-.163-.045-.273-.068-.42-.153.08-.143.241-.5.243-.572l-.063-.111c-.348.492-.93.97-1.414 1.245-.633.359-1.363.304-1.838.156-1.348-.415-2.623-1.327-2.93-1.566 0 0-.01.191.048.234.34.383 1.119 1.077 1.872 1.56l-1.605.177.759 5.908c-.337.048-.39.071-.757.124-.325-1.147-.946-1.895-1.624-2.332-.599-.384-1.424-.47-2.214-.314l-.05.059a2.851 2.851 0 0 1 1.863.444c.654.413 1.181 1.481 1.375 2.124.248.822.42 1.7-.248 2.632-.476.662-1.864 1.028-2.986.237.3.481.705.876 1.25.95.809.11 1.577-.03 2.106-.574.452-.464.69-1.434.628-2.456l.714-.104.258 1.834 11.827-1.424zM15.05 6.848c-.034.075-.085.125-.007.37l.004.014.013.032.032.073c.14.287.295.558.552.696.067-.011.136-.019.207-.023.242-.01.395.028.492.08.009-.048.01-.119.005-.222-.018-.364.072-.982-.626-1.308-.264-.122-.634-.084-.757.068a.302.302 0 0 1 .058.013c.186.066.06.13.027.207m1.958 3.392c-.092-.05-.52-.03-.821.005-.574.068-1.193.267-1.328.372-.247.191-.135.523.047.66.511.382.96.638 1.432.575.29-.038.546-.497.728-.914.124-.288.124-.598-.058-.698m-5.077-2.942c.162-.154-.805-.355-1.556.156-.554.378-.571 1.187-.041 1.646.053.046.096.078.137.104a4.77 4.77 0 0 1 1.396-.412c.113-.125.243-.345.21-.745-.044-.542-.455-.456-.146-.749" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const BetterStackIcon = ({ className }: Props) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="m.7792 10.7479-.7654 6.6384a2.0957 2.0957 0 0 0 .696 1.8122l1.8965 1.672c.6494.5725 1.658.0145 1.5185-.84L2.6039 10.705c-.1723-1.056-1.7022-1.02-1.8247.0429Zm12.3733 8.714L8.63 6.431c-.5023-1.4472-2.6082-1.3845-3.0203.0898l-1.376 4.9234c-.156.559-.216.8822.0005 1.4212h.5225l1.8993 6.0694c.294.7324.9017 1.3009 1.6611 1.5538l2.8315.9435c1.2417.4137 2.4268-.7513 2.004-1.97zm10.6297-1.0332L15.7907 3.4433c-.6971-1.3072-2.5779-1.2727-3.227.0589l-1.9652 3.9555c-.2375.487-.1274.6608.07.9435.1585.2268.526.2447.6758.012.147-.2287.488-.2076.6058.0375l5.1379 10.687a2.735 2.735 0 0 0 2.1416 1.6016l2.7183.3476c1.4628.1924 2.5299-1.3539 1.8343-2.6582z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ElasticsearchIcon = ({ className }: Props) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="#00BFB3"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M13.394 0C8.683 0 4.609 2.716 2.644 6.667h15.641a4.77 4.77 0 0 0 3.073-1.11c.446-.375.864-.785 1.247-1.243l.001-.002A11.974 11.974 0 0 0 13.394 0zM1.804 8.889a12.009 12.009 0 0 0 0 6.222h14.7a3.111 3.111 0 1 0 0-6.222zm.84 8.444C4.61 21.283 8.684 24 13.395 24c3.701 0 7.011-1.677 9.212-4.312l-.001-.002a9.958 9.958 0 0 0-1.247-1.243 4.77 4.77 0 0 0-3.073-1.11z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SplunkIcon = ({ className }: Props) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M23.348 11.911l-2.241-1.091v-.65L24 11.621v.593l-2.893 1.438v-.636zm-5.397 1.841h-.961v-5.31h.961v3.116h.102l1.28-1.481.723.31-1.23 1.316 1.453 1.809-.888.311-1.44-1.996zm-2.577-.002v-2.068a2.685 2.685 0 0 0-.026-.42.791.791 0 0 0-.09-.26c-.113-.202-.308-.304-.59-.304a.888.888 0 0 0-.461.113.673.673 0 0 0-.286.33 1.012 1.012 0 0 0-.07.263c-.012.13-.019.262-.017.395v1.95h-.961v-3.614h.961l.002.485c.185-.2.373-.348.566-.437.192-.089.418-.134.673-.134.286 0 .527.058.721.177a1.016 1.016 0 0 1 .475.665 1.972 1.972 0 0 1 .054.448c.002.1.004.22.004.358v2.053zm-4.115.002l-.002-.485a1.783 1.783 0 0 1-.565.437 1.597 1.597 0 0 1-.674.135c-.285 0-.524-.057-.72-.17a.972.972 0 0 1-.425-.504.75.75 0 0 1-.054-.167 1.918 1.918 0 0 1-.033-.199 2.033 2.033 0 0 1-.017-.258 15.516 15.516 0 0 1-.005-.355V10.13h.956v2.07c-.003.141.006.282.026.42.015.092.045.18.09.26.113.204.308.306.59.306.36 0 .606-.15.74-.449.035-.082.06-.168.074-.257.017-.134.024-.269.022-.403v-1.95h.955v3.624zM7.184 8.44h.955v5.31h-.955zM5.759 11.9c0-.396-.08-.708-.24-.937a.759.759 0 0 0-.657-.345.804.804 0 0 0-.693.366c-.171.245-.256.574-.253.99 0 .405.084.723.25.957a.796.796 0 0 0 .69.347.685.685 0 0 0 .433-.135.985.985 0 0 0 .277-.34c.071-.14.121-.292.147-.448.03-.151.043-.3.046-.455m1.01-.036c.003.266-.04.532-.129.786-.082.23-.204.441-.364.626-.31.361-.764.567-1.24.563a1.67 1.67 0 0 1-.313-.028 1.041 1.041 0 0 1-.275-.098 1.33 1.33 0 0 1-.257-.178 2.379 2.379 0 0 1-.265-.268v2.293h-.929v-5.425h.93l.004.529c.169-.212.353-.368.55-.468.197-.1.426-.15.688-.147a1.509 1.509 0 0 1 1.156.507c.148.166.259.361.33.571.08.236.12.485.115.737m-4.21.89a.946.946 0 0 1-.102.441 1.007 1.007 0 0 1-.282.345c-.13.1-.275.173-.43.22a1.8 1.8 0 0 1-.546.08 1.985 1.985 0 0 1-.637-.097 1.964 1.964 0 0 1-.563-.32l.312-.505c.15.126.284.217.405.275.115.057.24.087.368.087a.557.557 0 0 0 .373-.12.396.396 0 0 0 .14-.322.475.475 0 0 0-.12-.318 1.306 1.306 0 0 0-.187-.173 9.231 9.231 0 0 0-.308-.232 6.787 6.787 0 0 1-.281-.21 2.11 2.11 0 0 1-.252-.232 1.039 1.039 0 0 1-.18-.275.826.826 0 0 1-.069-.347.893.893 0 0 1 .094-.409.935.935 0 0 1 .255-.314 1.22 1.22 0 0 1 .39-.203c.16-.05.327-.074.494-.072.184 0 .368.026.545.076.174.05.338.123.488.219l-.282.454a1.05 1.05 0 0 0-.608-.201.504.504 0 0 0-.323.102.307.307 0 0 0-.126.253c0 .098.041.193.113.26.074.078.203.186.385.325.185.136.336.253.457.355.104.085.202.182.286.286.065.08.115.173.145.273a.808.808 0 0 1 .046.299Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PrometheusIcon = ({ className }: Props) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="#E6522C"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M12 0C5.373 0 0 5.372 0 12c0 6.627 5.373 12 12 12s12-5.373 12-12c0-6.628-5.373-12-12-12zm0 22.46c-1.885 0-3.414-1.26-3.414-2.814h6.828c0 1.553-1.528 2.813-3.414 2.813zm5.64-3.745H6.36v-2.046h11.28v2.046zm-.04-3.098H6.391c-.037-.043-.075-.086-.111-.13-1.155-1.401-1.427-2.133-1.69-2.879-.005-.025 1.4.287 2.395.511 0 0 .513.119 1.262.255-.72-.843-1.147-1.915-1.147-3.01 0-2.406 1.845-4.508 1.18-6.207.648.053 1.34 1.367 1.387 3.422.689-.951.977-2.69.977-3.755 0-1.103.727-2.385 1.454-2.429-.648 1.069.168 1.984.894 4.256.272.854.237 2.29.447 3.201.07-1.892.395-4.652 1.595-5.605-.529 1.2.079 2.702.494 3.424.671 1.164 1.078 2.047 1.078 3.716a4.642 4.642 0 01-1.11 2.996c.792-.149 1.34-.283 1.34-.283l2.573-.502s-.374 1.538-1.81 3.019z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const NewRelicIcon = ({ className }: Props) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="#1CE783"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M8.0015 14.3091v7.384L12.0008 24V12.0008L1.6078 5.9996v4.6167ZM12.0008 0 2.8232 5.2976 6.8209 7.606l5.1799-2.9893 6.3936 3.6913v7.384l-5.1783 2.9908v4.6167l9.176-5.2991V5.9996Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const InfluxDbIcon = ({ className }: Props) => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="#22ADF6"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M23.778 14.482l-2.287-9.959c-.13-.545-.624-1.09-1.169-1.248L9.87.051C9.74 0 9.584 0 9.426 0c-.443 0-.909.18-1.222.443L.716 7.412C.3 7.776.092 8.504.222 9.024l2.445 10.662c.13.545.624 1.092 1.169 1.248l9.775 3.015c.13.051.285.051.443.051.443 0 .91-.18 1.223-.443l8.007-7.435c.418-.39.624-1.092.494-1.64zM10.962 2.417l7.175 2.21c.285.08.285.21 0 .286l-3.77.858c-.285.08-.674-.05-.883-.26l-2.626-2.834c-.235-.232-.184-.336.104-.26zm4.47 12.872c.079.286-.105.444-.39.365l-7.748-2.392c-.285-.079-.338-.313-.13-.52l5.93-5.514c.209-.209.443-.13.52.156zM2.667 8.267l6.293-5.85c.21-.209.545-.18.754.025L12.86 5.85c.209.21.18.545-.026.754l-6.293 5.85c-.21.21-.545.181-.754-.025L2.64 9.024a.536.536 0 01.026-.757zm1.536 9.284L2.54 10.244c-.08-.285.05-.34.234-.13L5.4 12.949c.209.209.285.624.209.909L4.462 17.55c-.079.285-.208.285-.26 0zm9.202 4.264l-8.217-2.522a.547.547 0 01-.364-.675l1.378-4.421a.547.547 0 01.675-.365l8.216 2.522c.285.079.443.39.364.675L14.08 21.45a.553.553 0 01-.674.365zm7.279-5.98L15.2 20.93c-.209.209-.31.13-.234-.155l1.144-3.694c.079-.285.39-.573.674-.624l3.77-.858c.288-.076.339.054.13.234zm.598-1.09l-4.523 1.039a.534.534 0 01-.65-.39l-1.922-8.372a.534.534 0 01.39-.65L19.1 5.335a.534.534 0 01.649.39l1.923 8.371c.079.31-.102.596-.39.65Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const telemetryProviderIcons = {
|
||||||
|
loki: GrafanaLokiIcon,
|
||||||
|
prometheus_remote_write: PrometheusIcon,
|
||||||
|
new_relic: NewRelicIcon,
|
||||||
|
influxdb: InfluxDbIcon,
|
||||||
|
datadog: DatadogIcon,
|
||||||
|
aws_cloudwatch: AwsIcon,
|
||||||
|
splunk_hec: SplunkIcon,
|
||||||
|
elasticsearch: ElasticsearchIcon,
|
||||||
|
betterstack: BetterStackIcon,
|
||||||
|
} as const;
|
||||||
@@ -19,6 +19,10 @@ export const OnboardingLayout = ({ children, leftPanel }: Props) => {
|
|||||||
"\u201CThe Open Source alternative to Netlify, Vercel, Heroku.\u201D";
|
"\u201CThe Open Source alternative to Netlify, Vercel, Heroku.\u201D";
|
||||||
const logoUrl =
|
const logoUrl =
|
||||||
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined;
|
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined;
|
||||||
|
const logoLightUrl =
|
||||||
|
whitelabeling?.loginLogoLightUrl ||
|
||||||
|
whitelabeling?.logoLightUrl ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
|
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
|
||||||
@@ -28,7 +32,11 @@ export const OnboardingLayout = ({ children, leftPanel }: Props) => {
|
|||||||
href="/"
|
href="/"
|
||||||
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
|
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
|
||||||
>
|
>
|
||||||
<Logo className="size-10" logoUrl={logoUrl} />
|
<Logo
|
||||||
|
className="size-10"
|
||||||
|
logoUrl={logoUrl}
|
||||||
|
logoLightUrl={logoLightUrl}
|
||||||
|
/>
|
||||||
{appName}
|
{appName}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="relative z-20 mt-auto">
|
<div className="relative z-20 mt-auto">
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
Package,
|
Package,
|
||||||
Palette,
|
Palette,
|
||||||
|
ScrollText,
|
||||||
Server,
|
Server,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
@@ -350,7 +351,13 @@ const MENU: Menu = {
|
|||||||
icon: BotIcon,
|
icon: BotIcon,
|
||||||
url: "/dashboard/settings/ai",
|
url: "/dashboard/settings/ai",
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
isEnabled: ({ permissions }) => !!permissions?.organization.update,
|
isEnabled: ({ permissions }) =>
|
||||||
|
!!(
|
||||||
|
permissions?.ai.read ||
|
||||||
|
permissions?.ai.create ||
|
||||||
|
permissions?.ai.update ||
|
||||||
|
permissions?.ai.delete
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -374,6 +381,13 @@ const MENU: Menu = {
|
|||||||
icon: Package,
|
icon: Package,
|
||||||
isEnabled: ({ permissions }) => !!permissions?.registry.read,
|
isEnabled: ({ permissions }) => !!permissions?.registry.read,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
isSingle: true,
|
||||||
|
title: "Logs & Metrics",
|
||||||
|
url: "/dashboard/settings/logs-and-metrics",
|
||||||
|
icon: ScrollText,
|
||||||
|
isEnabled: ({ permissions }) => !!permissions?.telemetryProvider.read,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
title: "Secrets",
|
title: "Secrets",
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ const RESOURCE_LABELS: Record<string, string> = {
|
|||||||
settings: "Settings",
|
settings: "Settings",
|
||||||
session: "Session",
|
session: "Session",
|
||||||
network: "Network",
|
network: "Network",
|
||||||
|
telemetryProvider: "Logs & Metrics",
|
||||||
};
|
};
|
||||||
|
|
||||||
function MetadataCell({ metadata }: { metadata: string | null }) {
|
function MetadataCell({ metadata }: { metadata: string | null }) {
|
||||||
|
|||||||
@@ -180,6 +180,16 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
|
|||||||
description:
|
description:
|
||||||
"Manage DNS providers (Cloudflare, AWS Route53) and create, update, or delete their DNS records",
|
"Manage DNS providers (Cloudflare, AWS Route53) and create, update, or delete their DNS records",
|
||||||
},
|
},
|
||||||
|
telemetryProvider: {
|
||||||
|
label: "Logs & Metrics",
|
||||||
|
description:
|
||||||
|
"Manage the providers (Grafana Loki, Prometheus, Datadog, New Relic, etc.) that container logs and metrics are shipped to",
|
||||||
|
},
|
||||||
|
ai: {
|
||||||
|
label: "AI Settings",
|
||||||
|
description:
|
||||||
|
"Manage AI providers, models, and use AI features (log analysis, suggestions)",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Descriptions for each action within a resource */
|
/** Descriptions for each action within a resource */
|
||||||
@@ -475,6 +485,40 @@ const ACTION_META: Record<
|
|||||||
description: "Remove DNS providers and delete their records",
|
description: "Remove DNS providers and delete their records",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
telemetryProvider: {
|
||||||
|
read: {
|
||||||
|
label: "Read",
|
||||||
|
description: "View configured logs and metrics providers",
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
label: "Create",
|
||||||
|
description:
|
||||||
|
"Add new providers, test their connection and deploy the Vector agent",
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
label: "Delete",
|
||||||
|
description: "Remove logs and metrics providers",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ai: {
|
||||||
|
read: {
|
||||||
|
label: "Read",
|
||||||
|
description:
|
||||||
|
"View AI providers and use AI features (log analysis, suggestions)",
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
label: "Create",
|
||||||
|
description: "Add new AI provider configurations",
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
label: "Update",
|
||||||
|
description: "Edit AI provider settings and custom providers",
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
label: "Delete",
|
||||||
|
description: "Remove AI provider configurations",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Resources that should be hidden from the custom role editor (better-auth internals) */
|
/** Resources that should be hidden from the custom role editor (better-auth internals) */
|
||||||
@@ -586,6 +630,7 @@ const ROLE_PRESETS: {
|
|||||||
logs: ["read"],
|
logs: ["read"],
|
||||||
monitoring: ["read"],
|
monitoring: ["read"],
|
||||||
auditLog: ["read"],
|
auditLog: ["read"],
|
||||||
|
ai: ["read", "create", "update", "delete"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -12,12 +13,19 @@ interface WhitelabelingPreviewProps {
|
|||||||
config: {
|
config: {
|
||||||
appName?: string;
|
appName?: string;
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
|
logoLightUrl?: string;
|
||||||
|
dualModeBranding?: boolean;
|
||||||
footerText?: string;
|
footerText?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
|
export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
|
||||||
const appName = config.appName || "Dokploy";
|
const appName = config.appName || "Dokploy";
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const effectiveLogoUrl =
|
||||||
|
config.dualModeBranding && resolvedTheme === "light" && config.logoLightUrl
|
||||||
|
? config.logoLightUrl
|
||||||
|
: config.logoUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-transparent">
|
<Card className="bg-transparent">
|
||||||
@@ -31,9 +39,9 @@ export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
|
|||||||
<div className="rounded-lg border overflow-hidden">
|
<div className="rounded-lg border overflow-hidden">
|
||||||
{/* Simulated sidebar header */}
|
{/* Simulated sidebar header */}
|
||||||
<div className="flex items-center gap-3 p-4 border-b bg-sidebar">
|
<div className="flex items-center gap-3 p-4 border-b bg-sidebar">
|
||||||
{config.logoUrl ? (
|
{effectiveLogoUrl ? (
|
||||||
<img
|
<img
|
||||||
src={config.logoUrl}
|
src={effectiveLogoUrl}
|
||||||
alt="Preview Logo"
|
alt="Preview Logo"
|
||||||
className="size-8 rounded-sm object-contain"
|
className="size-8 rounded-sm object-contain"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { WhitelabelingPreview } from "./whitelabeling-preview";
|
import { WhitelabelingPreview } from "./whitelabeling-preview";
|
||||||
@@ -36,20 +37,42 @@ const safeUrlField = z
|
|||||||
message: "Only http:// and https:// URLs are allowed",
|
message: "Only http:// and https:// URLs are allowed",
|
||||||
});
|
});
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z
|
||||||
appName: z.string(),
|
.object({
|
||||||
appDescription: z.string(),
|
appName: z.string(),
|
||||||
logoUrl: safeUrlField,
|
appDescription: z.string(),
|
||||||
faviconUrl: safeUrlField,
|
logoUrl: safeUrlField,
|
||||||
customCss: z.string(),
|
faviconUrl: safeUrlField,
|
||||||
loginLogoUrl: safeUrlField,
|
customCss: z.string(),
|
||||||
supportUrl: safeUrlField,
|
loginLogoUrl: safeUrlField,
|
||||||
docsUrl: safeUrlField,
|
supportUrl: safeUrlField,
|
||||||
errorPageTitle: z.string(),
|
docsUrl: safeUrlField,
|
||||||
errorPageDescription: z.string(),
|
errorPageTitle: z.string(),
|
||||||
footerText: z.string(),
|
errorPageDescription: z.string(),
|
||||||
ogImageUrl: safeUrlField,
|
footerText: z.string(),
|
||||||
});
|
ogImageUrl: safeUrlField,
|
||||||
|
logoLightUrl: z.string(),
|
||||||
|
loginLogoLightUrl: z.string(),
|
||||||
|
faviconLightUrl: z.string(),
|
||||||
|
dualModeBranding: z.boolean(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (!data.dualModeBranding) return;
|
||||||
|
for (const field of [
|
||||||
|
"logoLightUrl",
|
||||||
|
"loginLogoLightUrl",
|
||||||
|
"faviconLightUrl",
|
||||||
|
] as const) {
|
||||||
|
const val = data[field];
|
||||||
|
if (val !== "" && !/^https?:\/\//i.test(val)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Only http:// and https:// URLs are allowed",
|
||||||
|
path: [field],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
type FormSchema = z.infer<typeof formSchema>;
|
type FormSchema = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
@@ -195,6 +218,10 @@ export function WhitelabelingSettings() {
|
|||||||
errorPageDescription: "",
|
errorPageDescription: "",
|
||||||
footerText: "",
|
footerText: "",
|
||||||
ogImageUrl: "",
|
ogImageUrl: "",
|
||||||
|
logoLightUrl: "",
|
||||||
|
loginLogoLightUrl: "",
|
||||||
|
faviconLightUrl: "",
|
||||||
|
dualModeBranding: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
});
|
});
|
||||||
@@ -214,6 +241,10 @@ export function WhitelabelingSettings() {
|
|||||||
errorPageDescription: data.errorPageDescription ?? "",
|
errorPageDescription: data.errorPageDescription ?? "",
|
||||||
footerText: data.footerText ?? "",
|
footerText: data.footerText ?? "",
|
||||||
ogImageUrl: data.ogImageUrl ?? "",
|
ogImageUrl: data.ogImageUrl ?? "",
|
||||||
|
logoLightUrl: data.logoLightUrl ?? "",
|
||||||
|
loginLogoLightUrl: data.loginLogoLightUrl ?? "",
|
||||||
|
faviconLightUrl: data.faviconLightUrl ?? "",
|
||||||
|
dualModeBranding: data.dualModeBranding ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form]);
|
||||||
@@ -244,6 +275,10 @@ export function WhitelabelingSettings() {
|
|||||||
errorPageDescription: values.errorPageDescription || null,
|
errorPageDescription: values.errorPageDescription || null,
|
||||||
footerText: values.footerText || null,
|
footerText: values.footerText || null,
|
||||||
ogImageUrl: values.ogImageUrl || null,
|
ogImageUrl: values.ogImageUrl || null,
|
||||||
|
logoLightUrl: values.logoLightUrl || null,
|
||||||
|
loginLogoLightUrl: values.loginLogoLightUrl || null,
|
||||||
|
faviconLightUrl: values.faviconLightUrl || null,
|
||||||
|
dualModeBranding: values.dualModeBranding,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -412,6 +447,111 @@ export function WhitelabelingSettings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Light Mode Logos Section */}
|
||||||
|
<Card className="bg-transparent">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<CardTitle>Light Mode Logos</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Use separate logos for light and dark themes. When enabled,
|
||||||
|
the logos in Branding are used for dark mode.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="dualModeBranding"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
aria-label="Enable light mode logos"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
field.onChange(checked);
|
||||||
|
if (!checked) {
|
||||||
|
form.clearErrors([
|
||||||
|
"logoLightUrl",
|
||||||
|
"loginLogoLightUrl",
|
||||||
|
"faviconLightUrl",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<div
|
||||||
|
className="grid transition-all duration-300 ease-in-out"
|
||||||
|
style={{
|
||||||
|
gridTemplateRows: form.watch("dualModeBranding")
|
||||||
|
? "1fr"
|
||||||
|
: "0fr",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="logoLightUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Logo URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/logo-light.svg"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="loginLogoLightUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Login Page Logo URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/login-logo-light.svg"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="faviconLightUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Favicon URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/favicon-light.ico"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Follows the browser/OS theme, not the website theme.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Appearance Section */}
|
{/* Appearance Section */}
|
||||||
<Card className="bg-transparent">
|
<Card className="bg-transparent">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -357,7 +357,10 @@ export const AdvanceBreadcrumb = () => {
|
|||||||
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
||||||
>
|
>
|
||||||
<FolderInput className="size-4 text-muted-foreground" />
|
<FolderInput className="size-4 text-muted-foreground" />
|
||||||
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
|
<span
|
||||||
|
className="font-medium max-w-[50px] md:max-w-[150px] truncate"
|
||||||
|
title={currentProject?.name}
|
||||||
|
>
|
||||||
{currentProject?.name || "Select Project"}
|
{currentProject?.name || "Select Project"}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="size-4 text-muted-foreground" />
|
<ChevronDown className="size-4 text-muted-foreground" />
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const dockerComposeServices = [
|
|||||||
{ label: "networks", type: "keyword", info: "Define networks" },
|
{ label: "networks", type: "keyword", info: "Define networks" },
|
||||||
{ label: "configs", type: "keyword", info: "Define configuration files" },
|
{ label: "configs", type: "keyword", info: "Define configuration files" },
|
||||||
{ label: "secrets", type: "keyword", info: "Define secrets" },
|
{ label: "secrets", type: "keyword", info: "Define secrets" },
|
||||||
|
{ label: "models", type: "keyword", info: "Define AI models" },
|
||||||
].map((opt) => ({
|
].map((opt) => ({
|
||||||
...opt,
|
...opt,
|
||||||
apply: (
|
apply: (
|
||||||
@@ -83,6 +84,11 @@ const dockerComposeServiceOptions = [
|
|||||||
},
|
},
|
||||||
{ label: "restart", type: "keyword", info: "Restart policy" },
|
{ label: "restart", type: "keyword", info: "Restart policy" },
|
||||||
{ label: "networks", type: "keyword", info: "Networks to join" },
|
{ label: "networks", type: "keyword", info: "Networks to join" },
|
||||||
|
{
|
||||||
|
label: "models",
|
||||||
|
type: "keyword",
|
||||||
|
info: "AI models to use, referencing top-level models",
|
||||||
|
},
|
||||||
].map((opt) => ({
|
].map((opt) => ({
|
||||||
...opt,
|
...opt,
|
||||||
apply: (
|
apply: (
|
||||||
|
|||||||
@@ -35,6 +35,16 @@
|
|||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"models": {
|
||||||
|
"id": "#/properties/models",
|
||||||
|
"type": "object",
|
||||||
|
"patternProperties": {
|
||||||
|
"^[a-zA-Z0-9._-]+$": {
|
||||||
|
"$ref": "#/definitions/model"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
"networks": {
|
"networks": {
|
||||||
"id": "#/properties/networks",
|
"id": "#/properties/networks",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -335,6 +345,30 @@
|
|||||||
"mem_swappiness": { "type": ["integer", "string"] },
|
"mem_swappiness": { "type": ["integer", "string"] },
|
||||||
"memswap_limit": { "type": ["number", "string"] },
|
"memswap_limit": { "type": ["number", "string"] },
|
||||||
"network_mode": { "type": "string" },
|
"network_mode": { "type": "string" },
|
||||||
|
"models": {
|
||||||
|
"oneOf": [
|
||||||
|
{ "$ref": "#/definitions/list_of_strings" },
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"patternProperties": {
|
||||||
|
"^[a-zA-Z0-9._-]+$": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"endpoint_var": { "type": "string" },
|
||||||
|
"model_var": { "type": "string" }
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"patternProperties": { "^x-": {} }
|
||||||
|
},
|
||||||
|
{ "type": "null" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"networks": {
|
"networks": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{ "$ref": "#/definitions/list_of_strings" },
|
{ "$ref": "#/definitions/list_of_strings" },
|
||||||
@@ -890,6 +924,23 @@
|
|||||||
"patternProperties": { "^x-": {} }
|
"patternProperties": { "^x-": {} }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"model": {
|
||||||
|
"id": "#/definitions/model",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"model": { "type": "string" },
|
||||||
|
"context_size": { "type": "integer" },
|
||||||
|
"runtime_flags": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["model"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"patternProperties": { "^x-": {} }
|
||||||
|
},
|
||||||
|
|
||||||
"command": {
|
"command": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{ "type": "null" },
|
{ "type": "null" },
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
|
import { useTheme } from "next-themes";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
|
logoLightUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Logo = ({ className = "size-14", logoUrl }: Props) => {
|
export const Logo = ({
|
||||||
if (logoUrl) {
|
className = "size-14",
|
||||||
|
logoUrl,
|
||||||
|
logoLightUrl,
|
||||||
|
}: Props) => {
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const effectiveUrl =
|
||||||
|
resolvedTheme === "light" && logoLightUrl ? logoLightUrl : logoUrl;
|
||||||
|
|
||||||
|
if (effectiveUrl) {
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/performance/noImgElement: this is for dynamic logo loading
|
|
||||||
<img
|
<img
|
||||||
src={logoUrl}
|
src={effectiveUrl}
|
||||||
alt="Organization Logo"
|
alt="Organization Logo"
|
||||||
className={cn(className, "object-contain rounded-sm")}
|
className={cn(className, "object-contain rounded-sm")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "compose" ADD COLUMN "pullImages" boolean DEFAULT false NOT NULL;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TYPE "public"."TelemetryProviderType" AS ENUM('loki', 'datadog', 'betterstack', 'elasticsearch', 'splunk_hec', 'aws_cloudwatch', 'prometheus_remote_write', 'new_relic', 'influxdb');--> statement-breakpoint
|
||||||
|
CREATE TABLE "telemetryProvider" (
|
||||||
|
"telemetryProviderId" text PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"providerType" "TelemetryProviderType" NOT NULL,
|
||||||
|
"signals" text[] NOT NULL,
|
||||||
|
"endpoint" text,
|
||||||
|
"apiKey" text,
|
||||||
|
"apiSecret" text,
|
||||||
|
"extraConfig" jsonb,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"createdAt" text NOT NULL,
|
||||||
|
"organizationId" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "server" ADD COLUMN "telemetryProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "webServerSettings" ADD COLUMN "telemetryProviderIds" text[] DEFAULT ARRAY[]::text[] NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "webServerSettings" ADD COLUMN "vectorAgentOrganizationId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "telemetryProvider" ADD CONSTRAINT "telemetryProvider_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "webServerSettings" ADD CONSTRAINT "webServerSettings_vectorAgentOrganizationId_organization_id_fk" FOREIGN KEY ("vectorAgentOrganizationId") REFERENCES "public"."organization"("id") ON DELETE set null ON UPDATE no action;
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1373,6 +1373,20 @@
|
|||||||
"when": 1788867370653,
|
"when": 1788867370653,
|
||||||
"tag": "0195_classy_whirlwind",
|
"tag": "0195_classy_whirlwind",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 196,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788995453103,
|
||||||
|
"tag": "0196_worthless_ravenous",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 197,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791456930312,
|
||||||
|
"tag": "0197_logs_and_metrics",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user