mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-19 07:01:35 +00:00
Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 749bb2c1ce | |||
| 5be17d3dd0 | |||
| 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 | |||
| 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 | |||
| 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^^}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
+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,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,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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pipes data larger than the pipe buffer unchanged", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-"));
|
||||||
|
const source = path.join(dir, "src.bin");
|
||||||
|
const target = path.join(dir, "dst.bin");
|
||||||
|
const size = 1024 * 1024;
|
||||||
|
const progress: number[] = [];
|
||||||
|
|
||||||
|
const bytes = await pipeBetweenServers({
|
||||||
|
source: {
|
||||||
|
serverId: null,
|
||||||
|
command: `head -c ${size} /dev/urandom | tee '${source}'`,
|
||||||
|
},
|
||||||
|
target: { serverId: null, command: `cat > '${target}'` },
|
||||||
|
onProgress: (transferred) => progress.push(transferred),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(bytes).toBe(size);
|
||||||
|
expect(progress.at(-1)).toBe(size);
|
||||||
|
expect(await fs.readFile(target)).toEqual(await fs.readFile(source));
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it("reports a target failure with its stderr", async () => {
|
||||||
|
await expect(
|
||||||
|
pipeBetweenServers({
|
||||||
|
source: { serverId: null, command: "printf x" },
|
||||||
|
target: { serverId: null, command: "echo boom >&2; exit 3" },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("target exited with code 3: boom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a source failure", async () => {
|
||||||
|
await expect(
|
||||||
|
pipeBetweenServers({
|
||||||
|
source: { serverId: null, command: "exit 2" },
|
||||||
|
target: { serverId: null, command: "cat > /dev/null" },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("source exited with code 2");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -282,6 +283,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">
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -121,7 +121,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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,7 +4,7 @@ import { useEffect } from "react";
|
|||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||||
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
||||||
import { HubSpotWidget } from "../shared/HubSpotWidget";
|
import { useHubSpotChat } from "../shared/analytics";
|
||||||
import Page from "./side";
|
import Page from "./side";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -26,6 +26,7 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isChatEnabled = isCloud === true && currentPlan === "startup";
|
const isChatEnabled = isCloud === true && currentPlan === "startup";
|
||||||
|
useHubSpotChat(isChatEnabled);
|
||||||
|
|
||||||
const { data: onboardingStatus } = api.project.onboardingStatus.useQuery();
|
const { data: onboardingStatus } = api.project.onboardingStatus.useQuery();
|
||||||
const shouldRedirectToOnboarding =
|
const shouldRedirectToOnboarding =
|
||||||
@@ -52,12 +53,6 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
|
|||||||
</Head>
|
</Head>
|
||||||
)}
|
)}
|
||||||
<Page>{children}</Page>
|
<Page>{children}</Page>
|
||||||
{isChatEnabled && (
|
|
||||||
<>
|
|
||||||
<HubSpotWidget />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{haveRootAccess === true && <ImpersonationBar />}
|
{haveRootAccess === true && <ImpersonationBar />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import Script from "next/script";
|
|
||||||
|
|
||||||
export const HubSpotWidget = () => {
|
|
||||||
return (
|
|
||||||
<Script
|
|
||||||
id="hs-script-loader"
|
|
||||||
type="text/javascript"
|
|
||||||
src="//js-eu1.hs-scripts.com/147033433.js"
|
|
||||||
strategy="lazyOnload"
|
|
||||||
async
|
|
||||||
defer
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -92,5 +92,11 @@ export const useHubSpotChat = (enabled: boolean) => {
|
|||||||
loadWidget,
|
loadWidget,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
return () => {
|
||||||
|
window.hsConversationsOnReady = window.hsConversationsOnReady?.filter(
|
||||||
|
(callback) => callback !== loadWidget,
|
||||||
|
);
|
||||||
|
window.HubSpotConversations?.widget.remove();
|
||||||
|
};
|
||||||
}, [enabled]);
|
}, [enabled]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "compose" ADD COLUMN "pullImages" boolean DEFAULT false NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1373,6 +1373,13 @@
|
|||||||
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export const isHttpsRequest = (
|
||||||
|
forwardedProto: string | string[] | undefined,
|
||||||
|
): boolean => {
|
||||||
|
if (!forwardedProto) return false;
|
||||||
|
const value = Array.isArray(forwardedProto)
|
||||||
|
? forwardedProto[0]
|
||||||
|
: forwardedProto;
|
||||||
|
// take the first value in case a proxy chain sent a list
|
||||||
|
return value?.split(",")[0]?.trim().toLowerCase() === "https";
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasSecureAttribute = (cookie: string): boolean =>
|
||||||
|
/;\s*secure(?:\s*;|\s*$)/i.test(cookie);
|
||||||
|
|
||||||
|
const withSecure = (cookie: string): string =>
|
||||||
|
hasSecureAttribute(cookie) ? cookie : `${cookie}; Secure`;
|
||||||
|
|
||||||
|
// Add Secure to a Set-Cookie header value (string or string[]).
|
||||||
|
export const secureSetCookie = (
|
||||||
|
value: string | number | readonly string[],
|
||||||
|
): string | number | string[] => {
|
||||||
|
if (typeof value === "number") return value;
|
||||||
|
if (Array.isArray(value)) return value.map(withSecure);
|
||||||
|
return withSecure(value as string);
|
||||||
|
};
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
"nanoid": "3.3.11",
|
"nanoid": "3.3.11",
|
||||||
"next": "16.3.0",
|
"next": "16.3.4",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"nextjs-toploader": "^3.9.17",
|
"nextjs-toploader": "^3.9.17",
|
||||||
"node-os-utils": "2.0.1",
|
"node-os-utils": "2.0.1",
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
"resend": "^6.0.2",
|
"resend": "^6.0.2",
|
||||||
"semver": "7.7.3",
|
"semver": "7.7.3",
|
||||||
"shadcn": "^4.11.0",
|
"shadcn": "^4.11.0",
|
||||||
"shell-quote": "^1.8.1",
|
"shell-quote": "^1.8.4",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"ssh2": "~1.16.0",
|
"ssh2": "~1.16.0",
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
"tsx": "^4.22.4",
|
"tsx": "^4.22.4",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vite-tsconfig-paths": "4.3.2",
|
"vite-tsconfig-paths": "4.3.2",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.1.0"
|
||||||
},
|
},
|
||||||
"ct3aMetadata": {
|
"ct3aMetadata": {
|
||||||
"initVersion": "7.25.2"
|
"initVersion": "7.25.2"
|
||||||
|
|||||||
@@ -1,7 +1,30 @@
|
|||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import { auth } from "@dokploy/server/index";
|
import { auth } from "@dokploy/server/index";
|
||||||
import { toNodeHandler } from "better-auth/node";
|
import { toNodeHandler } from "better-auth/node";
|
||||||
|
import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies";
|
||||||
|
|
||||||
// Disallow body parsing, we will parse it manually
|
// Disallow body parsing, we will parse it manually
|
||||||
export const config = { api: { bodyParser: false } };
|
export const config = { api: { bodyParser: false } };
|
||||||
|
|
||||||
export default toNodeHandler(auth.handler);
|
const handler = toNodeHandler(auth.handler);
|
||||||
|
|
||||||
|
// Mark the session cookie as Secure when the request comes over HTTPS
|
||||||
|
export default function authHandler(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
if (isHttpsRequest(req.headers["x-forwarded-proto"])) {
|
||||||
|
const setHeader = res.setHeader.bind(res);
|
||||||
|
res.setHeader = (name, value) =>
|
||||||
|
String(name).toLowerCase() === "set-cookie"
|
||||||
|
? setHeader(name, secureSetCookie(value))
|
||||||
|
: setHeader(name, value);
|
||||||
|
|
||||||
|
const appendHeader = res.appendHeader?.bind(res);
|
||||||
|
if (appendHeader) {
|
||||||
|
res.appendHeader = (name, value) =>
|
||||||
|
String(name).toLowerCase() === "set-cookie"
|
||||||
|
? appendHeader(name, secureSetCookie(value) as string | string[])
|
||||||
|
: appendHeader(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler(req, res);
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ const getPackageVersion = (headers: any, body: any) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: {
|
||||||
|
sizeLimit: "25mb",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse,
|
res: NextApiResponse,
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ import {
|
|||||||
logWebhookError,
|
logWebhookError,
|
||||||
} from "../[refreshToken]";
|
} from "../[refreshToken]";
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: {
|
||||||
|
sizeLimit: "25mb",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse,
|
res: NextApiResponse,
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ import {
|
|||||||
const getGithubRepositoryOwner = (githubBody: any) =>
|
const getGithubRepositoryOwner = (githubBody: any) =>
|
||||||
githubBody?.repository?.owner?.name ?? githubBody?.repository?.owner?.login;
|
githubBody?.repository?.owner?.name ?? githubBody?.repository?.owner?.login;
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: {
|
||||||
|
sizeLimit: "25mb",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse,
|
res: NextApiResponse,
|
||||||
|
|||||||
@@ -158,11 +158,9 @@ export default async function handler(
|
|||||||
newSubscription.customer as string,
|
newSubscription.customer as string,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!admin) {
|
// The account may already be gone when the deletion flow cancelled
|
||||||
return res.status(400).send("Webhook Error: Admin not found");
|
// the subscription; nothing left to disable.
|
||||||
}
|
if (!admin || admin.isEnterpriseCloud) {
|
||||||
|
|
||||||
if (admin.isEnterpriseCloud) {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,11 +312,7 @@ export default async function handler(
|
|||||||
const customer = event.data.object as Stripe.Customer;
|
const customer = event.data.object as Stripe.Customer;
|
||||||
|
|
||||||
const admin = await findUserByStripeCustomerId(customer.id);
|
const admin = await findUserByStripeCustomerId(customer.id);
|
||||||
if (!admin) {
|
if (!admin || admin.isEnterpriseCloud) {
|
||||||
return res.status(400).send("Webhook Error: Admin not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (admin.isEnterpriseCloud) {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
@@ -36,6 +36,7 @@ import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
|||||||
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
||||||
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
||||||
import { AssignNetworks } from "@/components/dashboard/networks/assign-networks";
|
import { AssignNetworks } from "@/components/dashboard/networks/assign-networks";
|
||||||
|
import { TransferService } from "@/components/dashboard/shared/transfer-service";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||||
@@ -194,6 +195,13 @@ const Service = (
|
|||||||
{permissions?.service.create && (
|
{permissions?.service.create && (
|
||||||
<UpdateApplication applicationId={applicationId} />
|
<UpdateApplication applicationId={applicationId} />
|
||||||
)}
|
)}
|
||||||
|
{permissions?.service.create && (
|
||||||
|
<TransferService
|
||||||
|
id={applicationId}
|
||||||
|
type="application"
|
||||||
|
serverId={data?.serverId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{permissions?.service.delete && (
|
{permissions?.service.delete && (
|
||||||
<DeleteService id={applicationId} type="application" />
|
<DeleteService id={applicationId} type="application" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+10
@@ -23,6 +23,7 @@ import { ShowSchedules } from "@/components/dashboard/application/schedules/show
|
|||||||
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
||||||
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
||||||
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
|
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
|
||||||
|
import { FreshVolumes } from "@/components/dashboard/compose/advanced/fresh-volumes";
|
||||||
import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers";
|
import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers";
|
||||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||||
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
|
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
|
||||||
@@ -33,6 +34,7 @@ import { ShowBackups } from "@/components/dashboard/database/backups/show-backup
|
|||||||
import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring";
|
import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring";
|
||||||
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
|
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
|
||||||
import { AssignComposeNetworks } from "@/components/dashboard/networks/assign-compose-networks";
|
import { AssignComposeNetworks } from "@/components/dashboard/networks/assign-compose-networks";
|
||||||
|
import { TransferService } from "@/components/dashboard/shared/transfer-service";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||||
@@ -184,6 +186,13 @@ const Service = (
|
|||||||
<UpdateCompose composeId={composeId} />
|
<UpdateCompose composeId={composeId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{permissions?.service.create && (
|
||||||
|
<TransferService
|
||||||
|
id={composeId}
|
||||||
|
type="compose"
|
||||||
|
serverId={data?.serverId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{permissions?.service.delete && (
|
{permissions?.service.delete && (
|
||||||
<DeleteService id={composeId} type="compose" />
|
<DeleteService id={composeId} type="compose" />
|
||||||
)}
|
)}
|
||||||
@@ -434,6 +443,7 @@ const Service = (
|
|||||||
<ShowImport composeId={composeId} />
|
<ShowImport composeId={composeId} />
|
||||||
<AssignComposeNetworks composeId={composeId} />
|
<AssignComposeNetworks composeId={composeId} />
|
||||||
<IsolatedDeploymentTab composeId={composeId} />
|
<IsolatedDeploymentTab composeId={composeId} />
|
||||||
|
<FreshVolumes composeId={composeId} />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+8
@@ -23,6 +23,7 @@ import { UpdateLibsql } from "@/components/dashboard/libsql/update-libsql";
|
|||||||
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
||||||
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
||||||
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
||||||
|
import { TransferService } from "@/components/dashboard/shared/transfer-service";
|
||||||
import { LibsqlIcon } from "@/components/icons/data-tools-icons";
|
import { LibsqlIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||||
@@ -145,6 +146,13 @@ const Libsql = (
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdateLibsql libsqlId={libsqlId} />
|
<UpdateLibsql libsqlId={libsqlId} />
|
||||||
|
{(auth?.role === "owner" || auth?.canCreateServices) && (
|
||||||
|
<TransferService
|
||||||
|
id={libsqlId}
|
||||||
|
type="libsql"
|
||||||
|
serverId={data?.serverId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
||||||
<DeleteService id={libsqlId} type="libsql" />
|
<DeleteService id={libsqlId} type="libsql" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+8
@@ -23,6 +23,7 @@ import { UpdateMariadb } from "@/components/dashboard/mariadb/update-mariadb";
|
|||||||
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
||||||
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
||||||
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
||||||
|
import { TransferService } from "@/components/dashboard/shared/transfer-service";
|
||||||
import { MariadbIcon } from "@/components/icons/data-tools-icons";
|
import { MariadbIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||||
@@ -159,6 +160,13 @@ const Mariadb = (
|
|||||||
{permissions?.service.create && (
|
{permissions?.service.create && (
|
||||||
<UpdateMariadb mariadbId={mariadbId} />
|
<UpdateMariadb mariadbId={mariadbId} />
|
||||||
)}
|
)}
|
||||||
|
{permissions?.service.create && (
|
||||||
|
<TransferService
|
||||||
|
id={mariadbId}
|
||||||
|
type="mariadb"
|
||||||
|
serverId={data?.serverId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{permissions?.service.delete && (
|
{permissions?.service.delete && (
|
||||||
<DeleteService id={mariadbId} type="mariadb" />
|
<DeleteService id={mariadbId} type="mariadb" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+8
@@ -23,6 +23,7 @@ import { UpdateMongo } from "@/components/dashboard/mongo/update-mongo";
|
|||||||
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
||||||
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
||||||
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
||||||
|
import { TransferService } from "@/components/dashboard/shared/transfer-service";
|
||||||
import { MongodbIcon } from "@/components/icons/data-tools-icons";
|
import { MongodbIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||||
@@ -159,6 +160,13 @@ const Mongo = (
|
|||||||
{permissions?.service.create && (
|
{permissions?.service.create && (
|
||||||
<UpdateMongo mongoId={mongoId} />
|
<UpdateMongo mongoId={mongoId} />
|
||||||
)}
|
)}
|
||||||
|
{permissions?.service.create && (
|
||||||
|
<TransferService
|
||||||
|
id={mongoId}
|
||||||
|
type="mongo"
|
||||||
|
serverId={data?.serverId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{permissions?.service.delete && (
|
{permissions?.service.delete && (
|
||||||
<DeleteService id={mongoId} type="mongo" />
|
<DeleteService id={mongoId} type="mongo" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+8
@@ -23,6 +23,7 @@ import { ShowGeneralMysql } from "@/components/dashboard/mysql/general/show-gene
|
|||||||
import { ShowInternalMysqlCredentials } from "@/components/dashboard/mysql/general/show-internal-mysql-credentials";
|
import { ShowInternalMysqlCredentials } from "@/components/dashboard/mysql/general/show-internal-mysql-credentials";
|
||||||
import { UpdateMysql } from "@/components/dashboard/mysql/update-mysql";
|
import { UpdateMysql } from "@/components/dashboard/mysql/update-mysql";
|
||||||
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
||||||
|
import { TransferService } from "@/components/dashboard/shared/transfer-service";
|
||||||
import { MysqlIcon } from "@/components/icons/data-tools-icons";
|
import { MysqlIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||||
@@ -159,6 +160,13 @@ const MySql = (
|
|||||||
{permissions?.service.create && (
|
{permissions?.service.create && (
|
||||||
<UpdateMysql mysqlId={mysqlId} />
|
<UpdateMysql mysqlId={mysqlId} />
|
||||||
)}
|
)}
|
||||||
|
{permissions?.service.create && (
|
||||||
|
<TransferService
|
||||||
|
id={mysqlId}
|
||||||
|
type="mysql"
|
||||||
|
serverId={data?.serverId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{permissions?.service.delete && (
|
{permissions?.service.delete && (
|
||||||
<DeleteService id={mysqlId} type="mysql" />
|
<DeleteService id={mysqlId} type="mysql" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+8
@@ -23,6 +23,7 @@ import { ShowGeneralPostgres } from "@/components/dashboard/postgres/general/sho
|
|||||||
import { ShowInternalPostgresCredentials } from "@/components/dashboard/postgres/general/show-internal-postgres-credentials";
|
import { ShowInternalPostgresCredentials } from "@/components/dashboard/postgres/general/show-internal-postgres-credentials";
|
||||||
import { UpdatePostgres } from "@/components/dashboard/postgres/update-postgres";
|
import { UpdatePostgres } from "@/components/dashboard/postgres/update-postgres";
|
||||||
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
||||||
|
import { TransferService } from "@/components/dashboard/shared/transfer-service";
|
||||||
import { PostgresqlIcon } from "@/components/icons/data-tools-icons";
|
import { PostgresqlIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||||
@@ -158,6 +159,13 @@ const Postgresql = (
|
|||||||
{permissions?.service.create && (
|
{permissions?.service.create && (
|
||||||
<UpdatePostgres postgresId={postgresId} />
|
<UpdatePostgres postgresId={postgresId} />
|
||||||
)}
|
)}
|
||||||
|
{permissions?.service.create && (
|
||||||
|
<TransferService
|
||||||
|
id={postgresId}
|
||||||
|
type="postgres"
|
||||||
|
serverId={data?.serverId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{permissions?.service.delete && (
|
{permissions?.service.delete && (
|
||||||
<DeleteService id={postgresId} type="postgres" />
|
<DeleteService id={postgresId} type="postgres" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+8
@@ -22,6 +22,7 @@ import { ShowGeneralRedis } from "@/components/dashboard/redis/general/show-gene
|
|||||||
import { ShowInternalRedisCredentials } from "@/components/dashboard/redis/general/show-internal-redis-credentials";
|
import { ShowInternalRedisCredentials } from "@/components/dashboard/redis/general/show-internal-redis-credentials";
|
||||||
import { UpdateRedis } from "@/components/dashboard/redis/update-redis";
|
import { UpdateRedis } from "@/components/dashboard/redis/update-redis";
|
||||||
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
||||||
|
import { TransferService } from "@/components/dashboard/shared/transfer-service";
|
||||||
import { RedisIcon } from "@/components/icons/data-tools-icons";
|
import { RedisIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||||
@@ -158,6 +159,13 @@ const Redis = (
|
|||||||
{permissions?.service.create && (
|
{permissions?.service.create && (
|
||||||
<UpdateRedis redisId={redisId} />
|
<UpdateRedis redisId={redisId} />
|
||||||
)}
|
)}
|
||||||
|
{permissions?.service.create && (
|
||||||
|
<TransferService
|
||||||
|
id={redisId}
|
||||||
|
type="redis"
|
||||||
|
serverId={data?.serverId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{permissions?.service.delete && (
|
{permissions?.service.delete && (
|
||||||
<DeleteService id={redisId} type="redis" />
|
<DeleteService id={redisId} type="redis" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,12 +12,10 @@ function SchedulesPage() {
|
|||||||
{(serverId) => (
|
{(serverId) => (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full min-h-[45vh]">
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full min-h-[45vh]">
|
||||||
<div className="rounded-xl bg-background shadow-md h-full">
|
<ShowSchedules
|
||||||
<ShowSchedules
|
scheduleType={serverId ? "server" : "dokploy-server"}
|
||||||
scheduleType={serverId ? "server" : "dokploy-server"}
|
id={serverId ?? "dokploy-server"}
|
||||||
id={serverId ?? "dokploy-server"}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { ReactElement } from "react";
|
|||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { ShowApiKeys } from "@/components/dashboard/settings/api/show-api-keys";
|
import { ShowApiKeys } from "@/components/dashboard/settings/api/show-api-keys";
|
||||||
import { LinkingAccount } from "@/components/dashboard/settings/linking-account/linking-account";
|
import { LinkingAccount } from "@/components/dashboard/settings/linking-account/linking-account";
|
||||||
|
import { DeleteAccount } from "@/components/dashboard/settings/profile/delete-account";
|
||||||
import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form";
|
import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
@@ -20,6 +21,7 @@ const Page = () => {
|
|||||||
<ProfileForm />
|
<ProfileForm />
|
||||||
{isCloud && <LinkingAccount />}
|
{isCloud && <LinkingAccount />}
|
||||||
{permissions?.api.read && <ShowApiKeys />}
|
{permissions?.api.read && <ShowApiKeys />}
|
||||||
|
<DeleteAccount />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -498,6 +498,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
// Prefetch the public branding so the login/onboarding logo and app name
|
// Prefetch the public branding so the login/onboarding logo and app name
|
||||||
// render correctly on the server (no flash of default branding).
|
// render correctly on the server (no flash of default branding).
|
||||||
await helpers.whitelabeling.getPublic.prefetch();
|
await helpers.whitelabeling.getPublic.prefetch();
|
||||||
|
await helpers.sso.showSignInWithSSO.prefetch();
|
||||||
|
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import { sshRouter } from "./routers/ssh-key";
|
|||||||
import { stripeRouter } from "./routers/stripe";
|
import { stripeRouter } from "./routers/stripe";
|
||||||
import { swarmRouter } from "./routers/swarm";
|
import { swarmRouter } from "./routers/swarm";
|
||||||
import { tagRouter } from "./routers/tag";
|
import { tagRouter } from "./routers/tag";
|
||||||
|
import { transferRouter } from "./routers/transfer";
|
||||||
import { userRouter } from "./routers/user";
|
import { userRouter } from "./routers/user";
|
||||||
import { vaultProviderRouter } from "./routers/vault-provider";
|
import { vaultProviderRouter } from "./routers/vault-provider";
|
||||||
import { volumeBackupsRouter } from "./routers/volume-backups";
|
import { volumeBackupsRouter } from "./routers/volume-backups";
|
||||||
@@ -120,6 +121,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
tag: tagRouter,
|
tag: tagRouter,
|
||||||
patch: patchRouter,
|
patch: patchRouter,
|
||||||
overview: overviewRouter,
|
overview: overviewRouter,
|
||||||
|
transfer: transferRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -545,7 +545,6 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
sourceType: "github",
|
sourceType: "github",
|
||||||
owner: input.owner,
|
owner: input.owner,
|
||||||
buildPath: input.buildPath,
|
buildPath: input.buildPath,
|
||||||
applicationStatus: "idle",
|
|
||||||
githubId: input.githubId,
|
githubId: input.githubId,
|
||||||
watchPaths: input.watchPaths,
|
watchPaths: input.watchPaths,
|
||||||
triggerType: input.triggerType,
|
triggerType: input.triggerType,
|
||||||
@@ -572,7 +571,6 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
gitlabBranch: input.gitlabBranch,
|
gitlabBranch: input.gitlabBranch,
|
||||||
gitlabBuildPath: input.gitlabBuildPath,
|
gitlabBuildPath: input.gitlabBuildPath,
|
||||||
sourceType: "gitlab",
|
sourceType: "gitlab",
|
||||||
applicationStatus: "idle",
|
|
||||||
gitlabId: input.gitlabId,
|
gitlabId: input.gitlabId,
|
||||||
gitlabProjectId: input.gitlabProjectId,
|
gitlabProjectId: input.gitlabProjectId,
|
||||||
gitlabPathNamespace: input.gitlabPathNamespace,
|
gitlabPathNamespace: input.gitlabPathNamespace,
|
||||||
@@ -601,7 +599,6 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
bitbucketBranch: input.bitbucketBranch,
|
bitbucketBranch: input.bitbucketBranch,
|
||||||
bitbucketBuildPath: input.bitbucketBuildPath,
|
bitbucketBuildPath: input.bitbucketBuildPath,
|
||||||
sourceType: "bitbucket",
|
sourceType: "bitbucket",
|
||||||
applicationStatus: "idle",
|
|
||||||
bitbucketId: input.bitbucketId,
|
bitbucketId: input.bitbucketId,
|
||||||
watchPaths: input.watchPaths,
|
watchPaths: input.watchPaths,
|
||||||
enableSubmodules: input.enableSubmodules,
|
enableSubmodules: input.enableSubmodules,
|
||||||
@@ -627,7 +624,6 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
giteaBranch: input.giteaBranch,
|
giteaBranch: input.giteaBranch,
|
||||||
giteaBuildPath: input.giteaBuildPath,
|
giteaBuildPath: input.giteaBuildPath,
|
||||||
sourceType: "gitea",
|
sourceType: "gitea",
|
||||||
applicationStatus: "idle",
|
|
||||||
giteaId: input.giteaId,
|
giteaId: input.giteaId,
|
||||||
watchPaths: input.watchPaths,
|
watchPaths: input.watchPaths,
|
||||||
enableSubmodules: input.enableSubmodules,
|
enableSubmodules: input.enableSubmodules,
|
||||||
@@ -652,7 +648,6 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
username: input.username,
|
username: input.username,
|
||||||
password: input.password,
|
password: input.password,
|
||||||
sourceType: "docker",
|
sourceType: "docker",
|
||||||
applicationStatus: "idle",
|
|
||||||
registryUrl: input.registryUrl,
|
registryUrl: input.registryUrl,
|
||||||
});
|
});
|
||||||
const application = await findApplicationById(input.applicationId);
|
const application = await findApplicationById(input.applicationId);
|
||||||
@@ -676,7 +671,6 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
customGitUrl: input.customGitUrl,
|
customGitUrl: input.customGitUrl,
|
||||||
customGitSSHKeyId: input.customGitSSHKeyId,
|
customGitSSHKeyId: input.customGitSSHKeyId,
|
||||||
sourceType: "git",
|
sourceType: "git",
|
||||||
applicationStatus: "idle",
|
|
||||||
watchPaths: input.watchPaths,
|
watchPaths: input.watchPaths,
|
||||||
enableSubmodules: input.enableSubmodules,
|
enableSubmodules: input.enableSubmodules,
|
||||||
});
|
});
|
||||||
@@ -729,7 +723,6 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
customGitSSHKeyId: null,
|
customGitSSHKeyId: null,
|
||||||
|
|
||||||
sourceType: "github", // Reset to default
|
sourceType: "github", // Reset to default
|
||||||
applicationStatus: "idle",
|
|
||||||
watchPaths: null,
|
watchPaths: null,
|
||||||
enableSubmodules: false,
|
enableSubmodules: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -759,7 +759,6 @@ export const composeRouter = createTRPCRouter({
|
|||||||
customGitSSHKeyId: null,
|
customGitSSHKeyId: null,
|
||||||
|
|
||||||
sourceType: "github", // Reset to default
|
sourceType: "github", // Reset to default
|
||||||
composeStatus: "idle",
|
|
||||||
watchPaths: null,
|
watchPaths: null,
|
||||||
enableSubmodules: false,
|
enableSubmodules: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ import {
|
|||||||
updateServerById,
|
updateServerById,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { findMemberByUserId } from "@dokploy/server/services/permission";
|
import {
|
||||||
|
findMemberByUserId,
|
||||||
|
hasPermission,
|
||||||
|
} from "@dokploy/server/services/permission";
|
||||||
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { observable } from "@trpc/server/observable";
|
import { observable } from "@trpc/server/observable";
|
||||||
@@ -51,6 +54,22 @@ import {
|
|||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
import { applyDockerCleanupSchedule } from "@/server/utils/docker-cleanup";
|
import { applyDockerCleanupSchedule } from "@/server/utils/docker-cleanup";
|
||||||
|
|
||||||
|
// Users without server.read only get what the server pickers render
|
||||||
|
const toServerOptions = async (
|
||||||
|
ctx: Parameters<typeof hasPermission>[0],
|
||||||
|
servers: (typeof server.$inferSelect)[],
|
||||||
|
) => {
|
||||||
|
await findMemberByUserId(ctx.user.id, ctx.session.activeOrganizationId);
|
||||||
|
if (await hasPermission(ctx, { server: ["read"] })) {
|
||||||
|
return servers;
|
||||||
|
}
|
||||||
|
return servers.map(({ serverId, name, ipAddress }) => ({
|
||||||
|
serverId,
|
||||||
|
name,
|
||||||
|
ipAddress,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
export const serverRouter = createTRPCRouter({
|
export const serverRouter = createTRPCRouter({
|
||||||
create: withPermission("server", "create")
|
create: withPermission("server", "create")
|
||||||
.input(apiCreateServer)
|
.input(apiCreateServer)
|
||||||
@@ -216,7 +235,7 @@ export const serverRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return servers.length ?? 0;
|
return servers.length ?? 0;
|
||||||
}),
|
}),
|
||||||
withSSHKey: withPermission("server", "read").query(async ({ ctx }) => {
|
withSSHKey: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const accessibleIds = await getAccessibleServerIds(ctx.session);
|
const accessibleIds = await getAccessibleServerIds(ctx.session);
|
||||||
|
|
||||||
const result = await db.query.server.findMany({
|
const result = await db.query.server.findMany({
|
||||||
@@ -234,9 +253,12 @@ export const serverRouter = createTRPCRouter({
|
|||||||
eq(server.serverType, "deploy"),
|
eq(server.serverType, "deploy"),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
return result.filter((s) => accessibleIds.has(s.serverId));
|
return toServerOptions(
|
||||||
|
ctx,
|
||||||
|
result.filter((s) => accessibleIds.has(s.serverId)),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
buildServers: withPermission("server", "read").query(async ({ ctx }) => {
|
buildServers: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const accessibleIds = await getAccessibleServerIds(ctx.session);
|
const accessibleIds = await getAccessibleServerIds(ctx.session);
|
||||||
|
|
||||||
const result = await db.query.server.findMany({
|
const result = await db.query.server.findMany({
|
||||||
@@ -254,7 +276,10 @@ export const serverRouter = createTRPCRouter({
|
|||||||
eq(server.serverType, "build"),
|
eq(server.serverType, "build"),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
return result.filter((s) => accessibleIds.has(s.serverId));
|
return toServerOptions(
|
||||||
|
ctx,
|
||||||
|
result.filter((s) => accessibleIds.has(s.serverId)),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
setup: withPermission("server", "create")
|
setup: withPermission("server", "create")
|
||||||
.input(apiFindOneServer)
|
.input(apiFindOneServer)
|
||||||
|
|||||||
@@ -388,6 +388,9 @@ export const stripeRouter = createTRPCRouter({
|
|||||||
await stripe.subscriptions.update(owner.stripeSubscriptionId, {
|
await stripe.subscriptions.update(owner.stripeSubscriptionId, {
|
||||||
items: updateItems,
|
items: updateItems,
|
||||||
proration_behavior: isTrialing ? "none" : "create_prorations",
|
proration_behavior: isTrialing ? "none" : "create_prorations",
|
||||||
|
...(isTrialing && subscription.trial_end
|
||||||
|
? { trial_end: subscription.trial_end }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
getAccessibleServerIds,
|
||||||
|
IS_CLOUD,
|
||||||
|
transferService,
|
||||||
|
} from "@dokploy/server";
|
||||||
|
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||||
|
import { audit } from "@/server/api/utils/audit";
|
||||||
|
import { apiTransferService } from "@/server/db/schema";
|
||||||
|
|
||||||
|
export const transferRouter = createTRPCRouter({
|
||||||
|
start: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
path: "/transfer/start",
|
||||||
|
method: "POST",
|
||||||
|
override: true,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(apiTransferService)
|
||||||
|
.subscription(async function* ({ input, ctx, signal }) {
|
||||||
|
await checkServicePermissionAndAccess(ctx, input.serviceId, {
|
||||||
|
service: ["create"],
|
||||||
|
deployment: ["create"],
|
||||||
|
});
|
||||||
|
if (input.targetServerId) {
|
||||||
|
const accessibleIds = await getAccessibleServerIds(ctx.session);
|
||||||
|
if (!accessibleIds.has(input.targetServerId)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You don't have access to the target server",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (IS_CLOUD) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "The Dokploy server is not available as a target",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue: string[] = [];
|
||||||
|
let done = false;
|
||||||
|
const log = (line: string) => queue.push(line);
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
const result = await transferService(
|
||||||
|
{
|
||||||
|
...input,
|
||||||
|
organizationId: ctx.session.activeOrganizationId,
|
||||||
|
},
|
||||||
|
log,
|
||||||
|
);
|
||||||
|
await audit(ctx, {
|
||||||
|
action: "move",
|
||||||
|
resourceType: "service",
|
||||||
|
resourceId: input.serviceId,
|
||||||
|
resourceName: result.appName,
|
||||||
|
metadata: { targetServerId: input.targetServerId },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
run()
|
||||||
|
.then(() => log("Transfer completed successfully!"))
|
||||||
|
.catch((error) => {
|
||||||
|
log(
|
||||||
|
`Transfer failed ❌ ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
done = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
while (!done || queue.length > 0) {
|
||||||
|
if (queue.length > 0) {
|
||||||
|
yield queue.shift()!;
|
||||||
|
} else {
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
|
ACCOUNT_DELETION_CODE_TTL_MINUTES,
|
||||||
|
consumeAccountDeletionCode,
|
||||||
|
createAccountDeletionCode,
|
||||||
createApiKey,
|
createApiKey,
|
||||||
createOrganizationUserWithCredentials,
|
createOrganizationUserWithCredentials,
|
||||||
|
findCredentialAccount,
|
||||||
findNotificationById,
|
findNotificationById,
|
||||||
findOrganizationById,
|
findOrganizationById,
|
||||||
findPasskeysByUserId,
|
findPasskeysByUserId,
|
||||||
@@ -11,6 +15,7 @@ import {
|
|||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
removeUserById,
|
removeUserById,
|
||||||
renderInvitationEmail,
|
renderInvitationEmail,
|
||||||
|
sendAccountDeletionCodeEmail,
|
||||||
sendEmailNotification,
|
sendEmailNotification,
|
||||||
sendResendNotification,
|
sendResendNotification,
|
||||||
updateUser,
|
updateUser,
|
||||||
@@ -24,6 +29,7 @@ import {
|
|||||||
apiUpdateUser,
|
apiUpdateUser,
|
||||||
invitation,
|
invitation,
|
||||||
member,
|
member,
|
||||||
|
organization,
|
||||||
session,
|
session,
|
||||||
user,
|
user,
|
||||||
} from "@dokploy/server/db/schema";
|
} from "@dokploy/server/db/schema";
|
||||||
@@ -38,6 +44,7 @@ import { and, asc, desc, eq, gt, ne } from "drizzle-orm";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { apiKeyNameSchema } from "@/lib/api-keys";
|
import { apiKeyNameSchema } from "@/lib/api-keys";
|
||||||
import { audit } from "@/server/api/utils/audit";
|
import { audit } from "@/server/api/utils/audit";
|
||||||
|
import { deleteAccount } from "@/server/utils/account-deletion";
|
||||||
import {
|
import {
|
||||||
adminProcedure,
|
adminProcedure,
|
||||||
createTRPCRouter,
|
createTRPCRouter,
|
||||||
@@ -63,6 +70,47 @@ const apiCreateApiKey = z.object({
|
|||||||
refillInterval: z.number().optional(),
|
refillInterval: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasRootAccess = (ctx: {
|
||||||
|
user: { id: string };
|
||||||
|
session: { impersonatedBy?: string } | null;
|
||||||
|
}) => {
|
||||||
|
const rootId = process.env.USER_ADMIN_ID;
|
||||||
|
if (!IS_CLOUD || !rootId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return rootId === ctx.user.id || ctx.session?.impersonatedBy === rootId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertCanDeleteOwnAccount = async (ctx: {
|
||||||
|
user: { id: string };
|
||||||
|
session: { impersonatedBy?: string };
|
||||||
|
}) => {
|
||||||
|
if (!IS_CLOUD) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "This feature is only available in Dokploy Cloud",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.session.impersonatedBy) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Accounts cannot be deleted while impersonating",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownedOrganization = await db.query.organization.findFirst({
|
||||||
|
where: eq(organization.ownerId, ctx.user.id),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
if (!ownedOrganization) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Only organization owners can delete their account",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const userRouter = createTRPCRouter({
|
export const userRouter = createTRPCRouter({
|
||||||
all: withPermission("member", "read").query(async ({ ctx }) => {
|
all: withPermission("member", "read").query(async ({ ctx }) => {
|
||||||
return await db.query.member.findMany({
|
return await db.query.member.findMany({
|
||||||
@@ -178,16 +226,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
return findPasskeysByUserId(ctx.user.id);
|
return findPasskeysByUserId(ctx.user.id);
|
||||||
}),
|
}),
|
||||||
haveRootAccess: protectedProcedure.query(async ({ ctx }) => {
|
haveRootAccess: protectedProcedure.query(async ({ ctx }) => {
|
||||||
if (!IS_CLOUD) {
|
return hasRootAccess(ctx);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
process.env.USER_ADMIN_ID === ctx.user.id ||
|
|
||||||
ctx.session?.impersonatedBy === process.env.USER_ADMIN_ID
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}),
|
}),
|
||||||
getBackups: adminProcedure.query(async ({ ctx }) => {
|
getBackups: adminProcedure.query(async ({ ctx }) => {
|
||||||
const memberResult = await db.query.member.findFirst({
|
const memberResult = await db.query.member.findFirst({
|
||||||
@@ -458,6 +497,97 @@ export const userRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
|
hasPassword: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const credential = await findCredentialAccount(ctx.user.id);
|
||||||
|
return !!credential?.password;
|
||||||
|
}),
|
||||||
|
requestAccountDeletionCode: protectedProcedure
|
||||||
|
.input(z.object({ password: z.string().optional() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await assertCanDeleteOwnAccount(ctx);
|
||||||
|
|
||||||
|
const credential = await findCredentialAccount(ctx.user.id);
|
||||||
|
if (
|
||||||
|
credential?.password &&
|
||||||
|
!bcrypt.compareSync(input.password ?? "", credential.password)
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "The password is incorrect",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await createAccountDeletionCode(ctx.user.id);
|
||||||
|
try {
|
||||||
|
await sendAccountDeletionCodeEmail({
|
||||||
|
email: ctx.user.email,
|
||||||
|
userName: ctx.user.name || ctx.user.email,
|
||||||
|
code,
|
||||||
|
expiresInMinutes: ACCOUNT_DELETION_CODE_TTL_MINUTES,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "The confirmation email could not be sent",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { expiresInMinutes: ACCOUNT_DELETION_CODE_TTL_MINUTES };
|
||||||
|
}),
|
||||||
|
deleteAccount: protectedProcedure
|
||||||
|
.input(z.object({ code: z.string().min(1) }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await assertCanDeleteOwnAccount(ctx);
|
||||||
|
|
||||||
|
const { requestedAt } = await consumeAccountDeletionCode(
|
||||||
|
ctx.user.id,
|
||||||
|
input.code,
|
||||||
|
);
|
||||||
|
|
||||||
|
await audit(ctx, {
|
||||||
|
action: "delete",
|
||||||
|
resourceType: "user",
|
||||||
|
resourceId: ctx.user.id,
|
||||||
|
metadata: { selfService: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return deleteAccount(ctx.user.id, ctx.user.id, { requestedAt });
|
||||||
|
}),
|
||||||
|
deleteAccountByEmail: protectedProcedure
|
||||||
|
.input(z.object({ email: z.string().email() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
if (!hasRootAccess(ctx)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Only the root account can delete accounts by email",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await db.query.user.findFirst({
|
||||||
|
where: eq(user.email, input.email.trim().toLowerCase()),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "No account found with that email",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.id === process.env.USER_ADMIN_ID) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "The root account cannot be deleted",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleteAccount(
|
||||||
|
target.id,
|
||||||
|
ctx.session.impersonatedBy ?? ctx.user.id,
|
||||||
|
);
|
||||||
|
}),
|
||||||
assignPermissions: withPermission("member", "update")
|
assignPermissions: withPermission("member", "update")
|
||||||
.input(apiAssignPermissions)
|
.input(apiAssignPermissions)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
deleteHubSpotContactByEmail,
|
||||||
|
deleteUserAccountData,
|
||||||
|
findUserById,
|
||||||
|
IS_CLOUD,
|
||||||
|
} from "@dokploy/server";
|
||||||
|
import { cancelStripeSubscriptions } from "./billing";
|
||||||
|
|
||||||
|
export const deleteAccount = async (
|
||||||
|
userId: string,
|
||||||
|
requestedBy: string,
|
||||||
|
options?: { requestedAt?: Date | null },
|
||||||
|
) => {
|
||||||
|
const target = await findUserById(userId);
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
const stripe =
|
||||||
|
IS_CLOUD && target.stripeCustomerId
|
||||||
|
? {
|
||||||
|
customerId: target.stripeCustomerId,
|
||||||
|
...(await cancelStripeSubscriptions(target.stripeCustomerId)),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const { organizationsDeleted } = await deleteUserAccountData(userId);
|
||||||
|
|
||||||
|
const hubspot = IS_CLOUD
|
||||||
|
? await deleteHubSpotContactByEmail(target.email)
|
||||||
|
: "skipped";
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
userId,
|
||||||
|
email: target.email,
|
||||||
|
requestedBy,
|
||||||
|
requestedAt: options?.requestedAt?.toISOString() ?? startedAt,
|
||||||
|
startedAt,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
organizationsDeleted,
|
||||||
|
stripe,
|
||||||
|
hubspot,
|
||||||
|
};
|
||||||
|
|
||||||
|
return record;
|
||||||
|
};
|
||||||
@@ -92,6 +92,7 @@ export interface BillingStatus {
|
|||||||
hasUsedTrial: boolean;
|
hasUsedTrial: boolean;
|
||||||
hasActiveAccess: boolean;
|
hasActiveAccess: boolean;
|
||||||
hasPaymentMethod: boolean;
|
hasPaymentMethod: boolean;
|
||||||
|
isAnnual: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getBillingStatus = async (
|
export const getBillingStatus = async (
|
||||||
@@ -106,6 +107,7 @@ export const getBillingStatus = async (
|
|||||||
hasUsedTrial: false,
|
hasUsedTrial: false,
|
||||||
hasActiveAccess: true,
|
hasActiveAccess: true,
|
||||||
hasPaymentMethod: true,
|
hasPaymentMethod: true,
|
||||||
|
isAnnual: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +121,7 @@ export const getBillingStatus = async (
|
|||||||
hasUsedTrial: false,
|
hasUsedTrial: false,
|
||||||
hasActiveAccess: false,
|
hasActiveAccess: false,
|
||||||
hasPaymentMethod: false,
|
hasPaymentMethod: false,
|
||||||
|
isAnnual: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +149,9 @@ export const getBillingStatus = async (
|
|||||||
const hasPaymentMethod =
|
const hasPaymentMethod =
|
||||||
!!currentSub?.default_payment_method ||
|
!!currentSub?.default_payment_method ||
|
||||||
!!customer?.invoice_settings?.default_payment_method;
|
!!customer?.invoice_settings?.default_payment_method;
|
||||||
|
const isAnnual =
|
||||||
|
(currentSub?.items.data[0]?.price as Stripe.Price | undefined)?.recurring
|
||||||
|
?.interval === "year";
|
||||||
const trialEndsAt = trialingSub?.trial_end
|
const trialEndsAt = trialingSub?.trial_end
|
||||||
? new Date(trialingSub.trial_end * 1000)
|
? new Date(trialingSub.trial_end * 1000)
|
||||||
: null;
|
: null;
|
||||||
@@ -164,5 +170,39 @@ export const getBillingStatus = async (
|
|||||||
hasPaymentMethod,
|
hasPaymentMethod,
|
||||||
hasUsedTrial: subscriptions.data.length > 0,
|
hasUsedTrial: subscriptions.data.length > 0,
|
||||||
hasActiveAccess: plan !== null || !!trialingSub,
|
hasActiveAccess: plan !== null || !!trialingSub,
|
||||||
|
isAnnual,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isMissingResource = (error: unknown) =>
|
||||||
|
error instanceof Stripe.errors.StripeError &&
|
||||||
|
error.code === "resource_missing";
|
||||||
|
|
||||||
|
export const cancelStripeSubscriptions = async (
|
||||||
|
stripeCustomerId: string,
|
||||||
|
stripe: Stripe = getStripeClient(),
|
||||||
|
) => {
|
||||||
|
const subscriptions = await stripe.subscriptions
|
||||||
|
.list({ customer: stripeCustomerId, status: "all", limit: 100 })
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (isMissingResource(error)) return null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelledSubscriptions: string[] = [];
|
||||||
|
for (const subscription of subscriptions?.data ?? []) {
|
||||||
|
if (
|
||||||
|
subscription.status === "canceled" ||
|
||||||
|
subscription.status === "incomplete_expired"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await stripe.subscriptions.cancel(subscription.id, {
|
||||||
|
invoice_now: false,
|
||||||
|
prorate: false,
|
||||||
|
});
|
||||||
|
cancelledSubscriptions.push(subscription.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cancelledSubscriptions };
|
||||||
|
};
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export const setupDeploymentLogsWebSocketServer = (
|
|||||||
const serverId = url.searchParams.get("serverId");
|
const serverId = url.searchParams.get("serverId");
|
||||||
const { user, session } = await validateRequest(req);
|
const { user, session } = await validateRequest(req);
|
||||||
|
|
||||||
|
// Client may have disconnected during the await; a later close handler would never fire.
|
||||||
|
if (ws.readyState !== ws.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Generate unique connection ID for tracking
|
// Generate unique connection ID for tracking
|
||||||
const connectionId = `deployment-logs-${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
const connectionId = `deployment-logs-${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
@@ -54,10 +59,33 @@ export const setupDeploymentLogsWebSocketServer = (
|
|||||||
let tailProcess: ReturnType<typeof spawn> | null = null;
|
let tailProcess: ReturnType<typeof spawn> | null = null;
|
||||||
let sshClient: Client | null = null;
|
let sshClient: Client | null = null;
|
||||||
|
|
||||||
|
// `killed` is set once a signal is sent, not when the process exits.
|
||||||
|
const isTailRunning = () =>
|
||||||
|
tailProcess !== null &&
|
||||||
|
tailProcess.exitCode === null &&
|
||||||
|
tailProcess.signalCode === null;
|
||||||
|
|
||||||
|
const stopTailProcess = () => {
|
||||||
|
if (!isTailRunning()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tailProcess!.kill("SIGTERM");
|
||||||
|
// Force kill after a timeout if it doesn't terminate
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isTailRunning()) {
|
||||||
|
tailProcess!.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
const server = await findServerById(serverId);
|
const server = await findServerById(serverId);
|
||||||
|
|
||||||
|
if (ws.readyState !== ws.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (server.organizationId !== session.activeOrganizationId) {
|
if (server.organizationId !== session.activeOrganizationId) {
|
||||||
ws.close();
|
ws.close();
|
||||||
return;
|
return;
|
||||||
@@ -155,30 +183,11 @@ export const setupDeploymentLogsWebSocketServer = (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("close", () => {
|
ws.on("close", stopTailProcess);
|
||||||
if (tailProcess && !tailProcess.killed) {
|
|
||||||
tailProcess.kill("SIGTERM");
|
|
||||||
// Force kill after a timeout if it doesn't terminate
|
|
||||||
setTimeout(() => {
|
|
||||||
if (tailProcess && !tailProcess.killed) {
|
|
||||||
tailProcess.kill("SIGKILL");
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Clean up resources on error
|
// Clean up resources on error
|
||||||
if (tailProcess && !tailProcess.killed) {
|
stopTailProcess();
|
||||||
tailProcess.kill("SIGTERM");
|
|
||||||
setTimeout(() => {
|
|
||||||
if (tailProcess && !tailProcess.killed) {
|
|
||||||
tailProcess.kill("SIGKILL");
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
if (sshClient) {
|
if (sshClient) {
|
||||||
sshClient.end();
|
sshClient.end();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ module github.com/mauriciogm/dokploy/apps/monitoring
|
|||||||
go 1.20
|
go 1.20
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gofiber/fiber/v2 v2.52.6
|
github.com/gofiber/fiber/v2 v2.52.11
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.24
|
github.com/mattn/go-sqlite3 v1.14.24
|
||||||
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,7 +21,6 @@ require (
|
|||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||||
github.com/rivo/uniseg v0.2.0 // indirect
|
github.com/rivo/uniseg v0.2.0 // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
|
||||||
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.14 // indirect
|
github.com/tklauser/go-sysconf v0.3.14 // indirect
|
||||||
github.com/tklauser/numcpus v0.8.0 // indirect
|
github.com/tklauser/numcpus v0.8.0 // indirect
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer5
|
|||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
|
github.com/gofiber/fiber/v2 v2.52.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs=
|
||||||
github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@@ -49,8 +49,6 @@ github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVS
|
|||||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
github.com/zcalusic/sysinfo v1.1.3 h1:u/AVENkuoikKuIZ4sUEJ6iibpmQP6YpGD8SSMCrqAF0=
|
|
||||||
github.com/zcalusic/sysinfo v1.1.3/go.mod h1:NX+qYnWGtJVPV0yWldff9uppNKU4h40hJIRPf/pGLv4=
|
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
+3
-1
@@ -48,7 +48,9 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"esbuild": "0.20.2",
|
"esbuild": "0.20.2",
|
||||||
"better-call": "1.3.7",
|
"better-call": "1.3.7",
|
||||||
"@better-fetch/fetch": "1.3.1"
|
"@better-fetch/fetch": "1.3.1",
|
||||||
|
"tar": ">=7.5.19 <8.0.0",
|
||||||
|
"protobufjs": ">=7.5.5 <8.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencyRules": {
|
"peerDependencyRules": {
|
||||||
"ignoreMissing": [
|
"ignoreMissing": [
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
"react-dom": "19.2.7",
|
"react-dom": "19.2.7",
|
||||||
"resend": "^6.0.2",
|
"resend": "^6.0.2",
|
||||||
"semver": "7.7.3",
|
"semver": "7.7.3",
|
||||||
"shell-quote": "^1.8.1",
|
"shell-quote": "^1.8.4",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"ssh2": "~1.16.0",
|
"ssh2": "~1.16.0",
|
||||||
"toml": "3.0.0",
|
"toml": "3.0.0",
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export const compose = pgTable("compose", {
|
|||||||
isolatedDeploymentsVolume: boolean("isolatedDeploymentsVolume")
|
isolatedDeploymentsVolume: boolean("isolatedDeploymentsVolume")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
pullImages: boolean("pullImages").notNull().default(false),
|
||||||
triggerType: triggerType("triggerType").default("push"),
|
triggerType: triggerType("triggerType").default("push"),
|
||||||
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
|
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
|
||||||
icon: text("icon"),
|
icon: text("icon"),
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export * from "./shared";
|
|||||||
export * from "./ssh-key";
|
export * from "./ssh-key";
|
||||||
export * from "./sso";
|
export * from "./sso";
|
||||||
export * from "./tag";
|
export * from "./tag";
|
||||||
|
export * from "./transfer";
|
||||||
export * from "./user";
|
export * from "./user";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
export * from "./vault-provider";
|
export * from "./vault-provider";
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import {
|
|||||||
UpdateConfigSwarmSchema,
|
UpdateConfigSwarmSchema,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
import {
|
import {
|
||||||
|
APP_NAME_MESSAGE,
|
||||||
|
APP_NAME_REGEX,
|
||||||
DATABASE_PASSWORD_MESSAGE,
|
DATABASE_PASSWORD_MESSAGE,
|
||||||
DATABASE_PASSWORD_REGEX,
|
DATABASE_PASSWORD_REGEX,
|
||||||
encryptedText,
|
encryptedText,
|
||||||
@@ -115,7 +117,7 @@ export const libsqlRelations = relations(libsql, ({ one, many }) => ({
|
|||||||
const createSchema = createInsertSchema(libsql, {
|
const createSchema = createInsertSchema(libsql, {
|
||||||
libsqlId: z.string(),
|
libsqlId: z.string(),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
appName: z.string().min(1),
|
appName: z.string().min(1).max(63).regex(APP_NAME_REGEX, APP_NAME_MESSAGE),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
databaseUser: z.string().min(1),
|
databaseUser: z.string().min(1),
|
||||||
databasePassword: z.string().regex(DATABASE_PASSWORD_REGEX, {
|
databasePassword: z.string().regex(DATABASE_PASSWORD_REGEX, {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { serviceType } from "./mount";
|
||||||
|
|
||||||
|
export const apiTransferService = z.object({
|
||||||
|
serviceType: z.enum(serviceType.enumValues),
|
||||||
|
serviceId: z.string().min(1),
|
||||||
|
targetServerId: z.string().min(1).nullable(),
|
||||||
|
removeSourceData: z.boolean().default(false),
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Link,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Tailwind,
|
||||||
|
Text,
|
||||||
|
} from "@react-email/components";
|
||||||
|
import { emailTailwindConfig } from "../tailwind-config";
|
||||||
|
|
||||||
|
export type TemplateProps = {
|
||||||
|
userName: string;
|
||||||
|
code: string;
|
||||||
|
expiresInMinutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AccountDeletionCodeTemplate = ({
|
||||||
|
userName = "User",
|
||||||
|
code = "123456",
|
||||||
|
expiresInMinutes = 10,
|
||||||
|
}: TemplateProps) => {
|
||||||
|
const previewText = `${code} is your account deletion code`;
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind config={emailTailwindConfig}>
|
||||||
|
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
|
||||||
|
<Container className="my-[40px] mx-auto max-w-[520px]">
|
||||||
|
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
|
||||||
|
<Img
|
||||||
|
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
|
||||||
|
width="190"
|
||||||
|
height="120"
|
||||||
|
alt="Dokploy"
|
||||||
|
className="my-0 mx-auto"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="bg-white px-[40px] py-[32px]">
|
||||||
|
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
|
||||||
|
Confirm your account deletion
|
||||||
|
</Heading>
|
||||||
|
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
|
||||||
|
Hello {userName}, we received a request to permanently delete
|
||||||
|
your Dokploy account. Enter the code below to confirm it.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section className="text-center mb-[24px]">
|
||||||
|
<Text className="inline-block bg-[#f4f4f5] rounded-lg text-[#09090b] text-[32px] font-semibold tracking-[8px] px-[24px] py-[16px] m-0">
|
||||||
|
{code}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Text className="text-[#a1a1aa] text-[13px] leading-[20px] m-0 text-center mb-[16px]">
|
||||||
|
This code expires in {expiresInMinutes} minutes. Once confirmed,
|
||||||
|
your subscriptions are cancelled and every organization you own
|
||||||
|
is deleted. This cannot be undone.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
|
||||||
|
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
|
||||||
|
If you did not request this, someone else may have access to
|
||||||
|
your account. Change your password and contact{" "}
|
||||||
|
<Link
|
||||||
|
href="https://dokploy.com"
|
||||||
|
className="text-[#71717a] underline"
|
||||||
|
>
|
||||||
|
Dokploy support
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccountDeletionCodeTemplate;
|
||||||
+1255
-3935
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ export * from "./db/validations/index";
|
|||||||
export * from "./lib/auth";
|
export * from "./lib/auth";
|
||||||
export * from "./lib/logger";
|
export * from "./lib/logger";
|
||||||
export * from "./monitoring/utils";
|
export * from "./monitoring/utils";
|
||||||
|
export * from "./services/account-deletion";
|
||||||
export * from "./services/admin";
|
export * from "./services/admin";
|
||||||
export * from "./services/application";
|
export * from "./services/application";
|
||||||
export * from "./services/backup";
|
export * from "./services/backup";
|
||||||
@@ -54,6 +55,7 @@ export * from "./services/server";
|
|||||||
export * from "./services/server-health";
|
export * from "./services/server-health";
|
||||||
export * from "./services/settings";
|
export * from "./services/settings";
|
||||||
export * from "./services/ssh-key";
|
export * from "./services/ssh-key";
|
||||||
|
export * from "./services/transfer";
|
||||||
export * from "./services/user";
|
export * from "./services/user";
|
||||||
export * from "./services/vault-provider";
|
export * from "./services/vault-provider";
|
||||||
export * from "./services/volume-backups";
|
export * from "./services/volume-backups";
|
||||||
@@ -120,6 +122,7 @@ export * from "./utils/notifications/dokploy-restart";
|
|||||||
export * from "./utils/notifications/server-threshold";
|
export * from "./utils/notifications/server-threshold";
|
||||||
export * from "./utils/notifications/utils";
|
export * from "./utils/notifications/utils";
|
||||||
export * from "./utils/process/execAsync";
|
export * from "./utils/process/execAsync";
|
||||||
|
export * from "./utils/process/remoteStream";
|
||||||
export * from "./utils/process/spawnAsync";
|
export * from "./utils/process/spawnAsync";
|
||||||
export * from "./utils/providers/bitbucket";
|
export * from "./utils/providers/bitbucket";
|
||||||
export * from "./utils/providers/docker";
|
export * from "./utils/providers/docker";
|
||||||
|
|||||||
@@ -126,6 +126,12 @@ const createBetterAuth = () =>
|
|||||||
...(await resolveTrustedOrigins()),
|
...(await resolveTrustedOrigins()),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
|
if (ctx.path === "/admin/remove-user") {
|
||||||
|
throw new APIError("FORBIDDEN", {
|
||||||
|
message: "Use the account deletion flow to remove users",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const isBlockedAuthPath =
|
const isBlockedAuthPath =
|
||||||
ctx.path.startsWith("/sign-in/email") ||
|
ctx.path.startsWith("/sign-in/email") ||
|
||||||
ctx.path.startsWith("/sign-in/social") ||
|
ctx.path.startsWith("/sign-in/social") ||
|
||||||
|
|||||||
@@ -59,6 +59,19 @@ export const recordAdvancedStats = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Filter out virtual devices (loop, ram, sr, etc.) - only include real disk devices
|
||||||
|
const NON_PHYSICAL_DEVICE_PATTERNS = [
|
||||||
|
/^(loop|ram|zram)/,
|
||||||
|
/^(sr|fd)\d+$/,
|
||||||
|
// Partitions and md/dm devices repeat I/O already counted on the underlying disk.
|
||||||
|
/^(sd|hd|vd|xvd)[a-z]+\d+$/,
|
||||||
|
/^(nvme\d+n\d+|mmcblk\d+)p\d+$/,
|
||||||
|
/^(md|dm-)/,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const isPhysicalDisk = (device: string) =>
|
||||||
|
!NON_PHYSICAL_DEVICE_PATTERNS.some((pattern) => pattern.test(device));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get host system statistics using node-os-utils
|
* Get host system statistics using node-os-utils
|
||||||
* This is used when monitoring "dokploy" to show host stats instead of container stats
|
* This is used when monitoring "dokploy" to show host stats instead of container stats
|
||||||
@@ -99,14 +112,9 @@ export const getHostSystemStats = async (): Promise<Container> => {
|
|||||||
let blockWriteBytes = 0;
|
let blockWriteBytes = 0;
|
||||||
const diskStats = await osutils.disk.stats();
|
const diskStats = await osutils.disk.stats();
|
||||||
if (diskStats.success && diskStats.data.length > 0) {
|
if (diskStats.success && diskStats.data.length > 0) {
|
||||||
// Filter out virtual devices (loop, ram, sr, etc.) - only include real disk devices
|
|
||||||
const excludePatterns = [/^loop/, /^ram/, /^sr\d+$/, /^fd\d+$/];
|
|
||||||
for (const stat of diskStats.data) {
|
for (const stat of diskStats.data) {
|
||||||
// Skip virtual devices
|
// Skip virtual, partition and stacked devices
|
||||||
if (
|
if (stat.device && !isPhysicalDisk(stat.device)) {
|
||||||
stat.device &&
|
|
||||||
excludePatterns.some((pattern) => pattern.test(stat.device))
|
|
||||||
) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// readBytes and writeBytes are DataSize objects with .toBytes() method
|
// readBytes and writeBytes are DataSize objects with .toBytes() method
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { createHash, randomInt, timingSafeEqual } from "node:crypto";
|
||||||
|
import { db } from "@dokploy/server/db";
|
||||||
|
import {
|
||||||
|
auditLog,
|
||||||
|
backups,
|
||||||
|
invitation,
|
||||||
|
organization,
|
||||||
|
user,
|
||||||
|
verification,
|
||||||
|
} from "@dokploy/server/db/schema";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
|
export const ANONYMIZED_EMAIL = "deleted-user";
|
||||||
|
export const ACCOUNT_DELETION_CODE_TTL_MINUTES = 10;
|
||||||
|
const ACCOUNT_DELETION_CODE_RESEND_SECONDS = 60;
|
||||||
|
const ACCOUNT_DELETION_CODE_MAX_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
const codeIdentifier = (userId: string) => `account-deletion:${userId}`;
|
||||||
|
|
||||||
|
const hashCode = (code: string) =>
|
||||||
|
createHash("sha256").update(code.trim()).digest("hex");
|
||||||
|
|
||||||
|
export const generateDeletionCode = () =>
|
||||||
|
randomInt(0, 1_000_000).toString().padStart(6, "0");
|
||||||
|
|
||||||
|
export const serializeDeletionCode = (code: string) =>
|
||||||
|
JSON.stringify({ hash: hashCode(code), attempts: 0 });
|
||||||
|
|
||||||
|
export const checkDeletionCode = (value: string, code: string) => {
|
||||||
|
const stored = JSON.parse(value) as { hash: string; attempts: number };
|
||||||
|
const expected = Buffer.from(stored.hash, "hex");
|
||||||
|
const actual = Buffer.from(hashCode(code), "hex");
|
||||||
|
|
||||||
|
if (expected.length === actual.length && timingSafeEqual(expected, actual)) {
|
||||||
|
return { valid: true as const, attempts: stored.attempts };
|
||||||
|
}
|
||||||
|
|
||||||
|
const attempts = stored.attempts + 1;
|
||||||
|
return {
|
||||||
|
valid: false as const,
|
||||||
|
attempts,
|
||||||
|
nextValue: JSON.stringify({ ...stored, attempts }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createAccountDeletionCode = async (userId: string) => {
|
||||||
|
const identifier = codeIdentifier(userId);
|
||||||
|
const existing = await db.query.verification.findFirst({
|
||||||
|
where: eq(verification.identifier, identifier),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
existing?.createdAt &&
|
||||||
|
Date.now() - existing.createdAt.getTime() <
|
||||||
|
ACCOUNT_DELETION_CODE_RESEND_SECONDS * 1000
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "TOO_MANY_REQUESTS",
|
||||||
|
message: `Wait ${ACCOUNT_DELETION_CODE_RESEND_SECONDS} seconds before requesting a new code`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(verification).where(eq(verification.identifier, identifier));
|
||||||
|
|
||||||
|
const code = generateDeletionCode();
|
||||||
|
const now = new Date();
|
||||||
|
await db.insert(verification).values({
|
||||||
|
id: nanoid(),
|
||||||
|
identifier,
|
||||||
|
value: serializeDeletionCode(code),
|
||||||
|
expiresAt: new Date(
|
||||||
|
now.getTime() + ACCOUNT_DELETION_CODE_TTL_MINUTES * 60_000,
|
||||||
|
),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
return code;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const consumeAccountDeletionCode = async (
|
||||||
|
userId: string,
|
||||||
|
code: string,
|
||||||
|
) => {
|
||||||
|
const row = await db.query.verification.findFirst({
|
||||||
|
where: eq(verification.identifier, codeIdentifier(userId)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Request a confirmation code first",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.expiresAt.getTime() < Date.now()) {
|
||||||
|
await db.delete(verification).where(eq(verification.id, row.id));
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "The code has expired, request a new one",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = checkDeletionCode(row.value, code);
|
||||||
|
if (!result.valid) {
|
||||||
|
if (result.attempts >= ACCOUNT_DELETION_CODE_MAX_ATTEMPTS) {
|
||||||
|
await db.delete(verification).where(eq(verification.id, row.id));
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Too many incorrect attempts, request a new code",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.update(verification)
|
||||||
|
.set({ value: result.nextValue, updatedAt: new Date() })
|
||||||
|
.where(eq(verification.id, row.id));
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "The code is incorrect",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(verification).where(eq(verification.id, row.id));
|
||||||
|
|
||||||
|
return { requestedAt: row.createdAt };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUserAccountData = async (userId: string) => {
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
const target = await tx.query.user.findFirst({
|
||||||
|
where: eq(user.id, userId),
|
||||||
|
columns: { id: true, email: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "User not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownedOrganizations = await tx.query.organization.findMany({
|
||||||
|
where: eq(organization.ownerId, userId),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
const organizationIds = ownedOrganizations.map((org) => org.id);
|
||||||
|
|
||||||
|
if (organizationIds.length > 0) {
|
||||||
|
await tx
|
||||||
|
.delete(auditLog)
|
||||||
|
.where(inArray(auditLog.organizationId, organizationIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions performed in organizations owned by someone else stay in
|
||||||
|
// their audit trail, but without the deleted user's PII.
|
||||||
|
await tx
|
||||||
|
.update(auditLog)
|
||||||
|
.set({ userEmail: ANONYMIZED_EMAIL })
|
||||||
|
.where(eq(auditLog.userId, userId));
|
||||||
|
|
||||||
|
await tx.delete(invitation).where(eq(invitation.email, target.email));
|
||||||
|
|
||||||
|
// backups.userId has no ON DELETE clause, so a leftover reference would
|
||||||
|
// abort the user delete.
|
||||||
|
await tx
|
||||||
|
.update(backups)
|
||||||
|
.set({ userId: null })
|
||||||
|
.where(eq(backups.userId, userId));
|
||||||
|
|
||||||
|
await tx.delete(user).where(eq(user.id, userId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
email: target.email,
|
||||||
|
organizationsDeleted: organizationIds.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user