mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-16 05:35:30 +00:00
Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 165746ca46 | |||
| 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 | |||
| 16ac4633f3 | |||
| d7d75f7c5c | |||
| a0e1001808 | |||
| 8236c31fcd | |||
| 037230496c | |||
| 14e059784c | |||
| f34151bc12 | |||
| d00ae31920 | |||
| 95b2712c26 | |||
| 318ad36967 | |||
| a87a5c06f0 | |||
| e120340e77 | |||
| 0d6e984b54 | |||
| 1e9873b3e4 | |||
| 26317e9126 | |||
| 853ca33659 | |||
| a90c2a662f | |||
| 45dcdfc5be | |||
| 29cdc815db | |||
| 9fa98de9c1 | |||
| a4a1d15656 | |||
| 8075a7ce95 | |||
| 326908869d | |||
| 18d07c7254 | |||
| 4cd4b83523 | |||
| f91b2e2ca6 | |||
| e70c880612 | |||
| 0c1dfe978c | |||
| 6dcd0e1859 | |||
| 96778433db | |||
| f25810fa72 | |||
| 341f38bba6 | |||
| f67fab0675 | |||
| 27dab3f4bf | |||
| f5c21db1ef |
@@ -14,34 +14,34 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get version from package.json
|
||||
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> $GITHUB_ENV
|
||||
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Get latest GitHub tag
|
||||
run: |
|
||||
LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1)
|
||||
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV
|
||||
echo $LATEST_TAG
|
||||
echo "LATEST_TAG=$LATEST_TAG" >> "$GITHUB_ENV"
|
||||
echo "$LATEST_TAG"
|
||||
- name: Compare versions
|
||||
run: |
|
||||
if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then
|
||||
if [ "$VERSION" != "$LATEST_TAG" ]; then
|
||||
VERSION_CHANGED="true"
|
||||
else
|
||||
VERSION_CHANGED="false"
|
||||
fi
|
||||
echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV
|
||||
echo "VERSION_CHANGED=$VERSION_CHANGED" >> "$GITHUB_ENV"
|
||||
echo "Comparing versions:"
|
||||
echo "Current version: ${{ env.VERSION }}"
|
||||
echo "Latest tag: ${{ env.LATEST_TAG }}"
|
||||
echo "Current version: $VERSION"
|
||||
echo "Latest tag: $LATEST_TAG"
|
||||
echo "Version changed: $VERSION_CHANGED"
|
||||
- name: Check if a PR already exists
|
||||
run: |
|
||||
PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length')
|
||||
echo "PR_EXISTS=$PR_EXISTS" >> $GITHUB_ENV
|
||||
echo "PR_EXISTS=$PR_EXISTS" >> "$GITHUB_ENV"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_PAT }}
|
||||
|
||||
@@ -56,12 +56,12 @@ jobs:
|
||||
git push origin canary
|
||||
|
||||
gh pr create \
|
||||
--title "🚀 Release ${{ env.VERSION }}" \
|
||||
--body '
|
||||
This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}.
|
||||
--title "🚀 Release $VERSION" \
|
||||
--body "
|
||||
This PR promotes changes from \`canary\` to \`main\` for version $VERSION.
|
||||
|
||||
### 🔍 Changes Include:
|
||||
- Version bump to ${{ env.VERSION }}
|
||||
- Version bump to $VERSION
|
||||
- All changes from canary branch
|
||||
|
||||
### ✅ Pre-merge Checklist:
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
- [ ] Documentation updated
|
||||
- [ ] Docker images built and tested
|
||||
|
||||
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)' \
|
||||
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)" \
|
||||
--base main \
|
||||
--head canary \
|
||||
--label "release" --label "automated pr" || true \
|
||||
|
||||
@@ -5,33 +5,38 @@ on:
|
||||
branches: [main, canary]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-push-cloud-image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set tag and version
|
||||
id: meta-cloud
|
||||
run: |
|
||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT
|
||||
echo "tags=siumauricio/cloud:canary" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.cloud
|
||||
@@ -48,26 +53,28 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set tag and version
|
||||
id: meta-schedule
|
||||
run: |
|
||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT
|
||||
echo "tags=siumauricio/schedule:canary" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.schedule
|
||||
@@ -80,26 +87,28 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set tag and version
|
||||
id: meta-server
|
||||
run: |
|
||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT
|
||||
echo "tags=siumauricio/server:canary" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.server
|
||||
|
||||
@@ -8,18 +8,23 @@ on:
|
||||
env:
|
||||
IMAGE_NAME: dokploy/dokploy
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker-amd:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -27,15 +32,15 @@ jobs:
|
||||
- name: Set tag and version
|
||||
id: meta
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
TAG="latest"
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
else
|
||||
TAG="feature"
|
||||
fi
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Prepare env file
|
||||
run: |
|
||||
@@ -43,7 +48,7 @@ jobs:
|
||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
@@ -53,13 +58,15 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -68,15 +75,15 @@ jobs:
|
||||
id: meta
|
||||
run: |
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
TAG="latest"
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
else
|
||||
TAG="feature"
|
||||
fi
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Prepare env file
|
||||
run: |
|
||||
@@ -84,7 +91,7 @@ jobs:
|
||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
@@ -96,20 +103,22 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifests
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
TAG="latest"
|
||||
|
||||
@@ -121,7 +130,7 @@ jobs:
|
||||
${IMAGE_NAME}:${TAG}-amd64 \
|
||||
${IMAGE_NAME}:${TAG}-arm64
|
||||
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||
${IMAGE_NAME}:${TAG}-amd64 \
|
||||
@@ -138,32 +147,37 @@ jobs:
|
||||
needs: [combine-manifests]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
npm_version: ${{ steps.get_version.outputs.npm_version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "npm_version=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "npm_version=${VERSION#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Fetch install.sh
|
||||
env:
|
||||
RELEASE_VERSION: ${{ steps.get_version.outputs.version }}
|
||||
run: |
|
||||
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
|
||||
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
|
||||
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
|
||||
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
|
||||
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${RELEASE_VERSION}}\""; tail -n +2 install.sh; } > install-pinned.sh
|
||||
mv install-pinned.sh install.sh
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.get_version.outputs.version }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
@@ -179,15 +193,19 @@ jobs:
|
||||
needs: [generate-release]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NPM_VERSION: ${{ needs.generate-release.outputs.npm_version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.22.0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.4.0
|
||||
cache: pnpm
|
||||
@@ -202,63 +220,63 @@ jobs:
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
|
||||
cd /tmp/mcp-repo
|
||||
|
||||
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
cp ${{ github.workspace }}/openapi.json src/generated/openapi.json
|
||||
cp $GITHUB_WORKSPACE/openapi.json src/generated/openapi.json
|
||||
pnpm install
|
||||
pnpm run generate
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
git add -A
|
||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||
--allow-empty
|
||||
git push
|
||||
|
||||
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||
echo "✅ MCP repo synced to version $NPM_VERSION"
|
||||
|
||||
- name: Sync version to CLI repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
|
||||
cd /tmp/cli-repo
|
||||
|
||||
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
||||
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
|
||||
pnpm install
|
||||
pnpm run generate
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
git add -A
|
||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||
--allow-empty
|
||||
git push
|
||||
|
||||
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||
echo "✅ CLI repo synced to version $NPM_VERSION"
|
||||
|
||||
- name: Sync version to SDK repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
|
||||
cd /tmp/sdk-repo
|
||||
|
||||
jq --arg v "${{ needs.generate-release.outputs.npm_version }}" '.version = $v' package.json > package.json.tmp
|
||||
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
||||
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
|
||||
pnpm install
|
||||
pnpm run generate
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
git add -A
|
||||
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.npm_version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||
--allow-empty
|
||||
git push
|
||||
|
||||
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.npm_version }}"
|
||||
echo "✅ SDK repo synced to version $NPM_VERSION"
|
||||
|
||||
@@ -6,15 +6,20 @@ on:
|
||||
pull_request:
|
||||
branches: [canary]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup biomeJs
|
||||
uses: biomejs/setup-biome@v2
|
||||
uses: biomejs/setup-biome@v2
|
||||
|
||||
- name: Run Biome formatter
|
||||
run: biome format --write
|
||||
|
||||
@@ -16,17 +16,19 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
||||
|
||||
- name: Cherry-pick fix to main
|
||||
env:
|
||||
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
run: |
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
SHA="${{ github.event.pull_request.merge_commit_sha }}"
|
||||
SHA="$MERGE_SHA"
|
||||
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
|
||||
git cherry-pick -x -m 1 "$SHA"
|
||||
else
|
||||
|
||||
@@ -7,12 +7,16 @@ concurrency:
|
||||
group: hotfix-to-main
|
||||
cancel-in-progress: false
|
||||
|
||||
# Push to main goes through HOTFIX_PUSH_TOKEN, not the workflow token.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
||||
|
||||
@@ -7,18 +7,23 @@ on:
|
||||
env:
|
||||
IMAGE_NAME: dokploy/monitoring
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker-amd:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -26,17 +31,17 @@ jobs:
|
||||
- name: Set tag
|
||||
id: meta
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
TAG="latest"
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
else
|
||||
TAG="feature"
|
||||
fi
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.monitoring
|
||||
@@ -47,13 +52,15 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -61,17 +68,17 @@ jobs:
|
||||
- name: Set
|
||||
id: meta
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
TAG="latest"
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
else
|
||||
TAG="feature"
|
||||
fi
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.monitoring
|
||||
@@ -84,27 +91,29 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifests
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
TAG="latest"
|
||||
|
||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||
${IMAGE_NAME}:${TAG}-amd64 \
|
||||
${IMAGE_NAME}:${TAG}-arm64
|
||||
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||
${IMAGE_NAME}:${TAG}-amd64 \
|
||||
|
||||
@@ -14,9 +14,11 @@ jobs:
|
||||
matrix:
|
||||
job: [build, test, typecheck]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.4.0
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -12,17 +12,20 @@ on:
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
generate-and-commit:
|
||||
name: Generate OpenAPI and commit to Dokploy repo
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Dokploy repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.4.0
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -28,6 +28,9 @@ env:
|
||||
DOKPLOY_SERVICE: dokploy
|
||||
DOKPLOY_PORT: 3000
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
jobs:
|
||||
|
||||
@@ -421,8 +424,10 @@ jobs:
|
||||
# ── Job summary ────────────────────────────────────────────────────────
|
||||
- name: Write job summary
|
||||
if: always()
|
||||
env:
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
run: |
|
||||
STATUS="${{ job.status }}"
|
||||
STATUS="$JOB_STATUS"
|
||||
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
|
||||
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
|
||||
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
|
||||
|
||||
@@ -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 { getS3Credentials } from "@dokploy/server/utils/backups/utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("redactRcloneCredentials (#4621)", () => {
|
||||
@@ -48,3 +50,48 @@ describe("redactRcloneCredentials (#4621)", () => {
|
||||
expect(redacted).toContain("[REDACTED]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactRcloneCredentials with getS3Credentials output (#5519)", () => {
|
||||
const buildCommand = (accessKey: string, secretAccessKey: string) =>
|
||||
`rclone rcat ${getS3Credentials({
|
||||
accessKey,
|
||||
secretAccessKey,
|
||||
region: "us-west-001",
|
||||
endpoint: "https://s3.us-west-001.backblazeb2.com",
|
||||
provider: "Other",
|
||||
} as Destination).join(" ")} :s3:bucket/file.gz`;
|
||||
|
||||
it.each([
|
||||
[
|
||||
"plain alphanumeric",
|
||||
"001aaaabbbbccccdd0000000001",
|
||||
"K001FAKEfakeFAKEfake",
|
||||
],
|
||||
[
|
||||
"containing slashes",
|
||||
"AKIAIOSFODNN7EXAMPLE",
|
||||
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
],
|
||||
["containing spaces", "key with space", "secret with space"],
|
||||
["containing quotes", "it's-a-key", `say "hi" it's`],
|
||||
["containing shell metacharacters", "key$HOME", "sec;ret`id`&|\\x"],
|
||||
])("redacts credentials %s", (_, accessKey, secretAccessKey) => {
|
||||
const redacted = redactRcloneCredentials(
|
||||
buildCommand(accessKey, secretAccessKey),
|
||||
);
|
||||
expect(redacted).toContain(
|
||||
'--s3-access-key-id="[REDACTED]" --s3-secret-access-key="[REDACTED]" --s3-region=us-west-001',
|
||||
);
|
||||
expect(redacted).not.toContain(accessKey);
|
||||
expect(redacted).not.toContain(secretAccessKey);
|
||||
expect(redacted).toContain(":s3:bucket/file.gz");
|
||||
});
|
||||
|
||||
it("redacts credentials embedded in an error string", () => {
|
||||
const errorStr = `Error: Command failed: ${buildCommand("MYKEY", "MY/SECRET")}`;
|
||||
const redacted = redactRcloneCredentials(errorStr);
|
||||
expect(redacted).not.toContain("MYKEY");
|
||||
expect(redacted).not.toContain("MY/SECRET");
|
||||
expect(redacted).toContain("[REDACTED]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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");
|
||||
});
|
||||
|
||||
it("asks for imported secrets and merges them", async () => {
|
||||
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
secrets: [],
|
||||
imports: [
|
||||
{
|
||||
secretPath: "/shared",
|
||||
secrets: [
|
||||
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||
|
||||
expect(result).toEqual({ DB_URL: "postgres://imported" });
|
||||
const [listUrl] = mockFetch.mock.calls[1] as [string];
|
||||
// snake_case on purpose: `includeImports` is silently ignored and the
|
||||
// response comes back with an empty `imports` array.
|
||||
expect(new URL(listUrl).searchParams.get("include_imports")).toBe("true");
|
||||
});
|
||||
|
||||
it("lets the later import win when two define the same key", async () => {
|
||||
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
secrets: [],
|
||||
imports: [
|
||||
{
|
||||
secretPath: "/base",
|
||||
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://base" }],
|
||||
},
|
||||
{
|
||||
secretPath: "/override",
|
||||
secrets: [
|
||||
{ secretKey: "DB_URL", secretValue: "postgres://override" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||
|
||||
// Infisical documents this order: "If two imports carry a secret with the
|
||||
// same name, the value from the bottom-most import wins." The response
|
||||
// lists imports in that order, so assigning them in sequence matches it.
|
||||
expect(result).toEqual({ DB_URL: "postgres://override" });
|
||||
});
|
||||
|
||||
it("lets a folder's own secret win over an imported one of the same name", async () => {
|
||||
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://local" }],
|
||||
imports: [
|
||||
{
|
||||
secretPath: "/shared",
|
||||
secrets: [
|
||||
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||
|
||||
expect(result).toEqual({ DB_URL: "postgres://local" });
|
||||
});
|
||||
|
||||
it("throws a clear error for a missing secret", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import {
|
||||
buildSandboxContainerOptions,
|
||||
getSandboxNetworkName,
|
||||
parseSandboxEnvVars,
|
||||
parseSandboxUser,
|
||||
SANDBOX_INTERNET_NETWORK,
|
||||
SANDBOX_ISOLATED_NETWORK,
|
||||
} from "@dokploy/server/utils/sandbox/container";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const spec = {
|
||||
sandboxId: "sb_1",
|
||||
projectId: "proj_1",
|
||||
environmentId: "env_1",
|
||||
image: "python:3.12-slim",
|
||||
cpu: 1.5,
|
||||
memoryMb: 512,
|
||||
pidsLimit: 256,
|
||||
networkMode: "isolated" as const,
|
||||
envVars: "FOO=bar\n# comment\n\nBAZ=qux=1\ninvalid",
|
||||
workdir: "/home/user",
|
||||
user: "1000:1000",
|
||||
};
|
||||
|
||||
describe("buildSandboxContainerOptions", () => {
|
||||
it("applies cpu, memory and pids limits", () => {
|
||||
const options = buildSandboxContainerOptions(spec);
|
||||
expect(options.HostConfig?.NanoCpus).toBe(1_500_000_000);
|
||||
expect(options.HostConfig?.Memory).toBe(512 * 1024 * 1024);
|
||||
expect(options.HostConfig?.MemorySwap).toBe(512 * 1024 * 1024);
|
||||
expect(options.HostConfig?.PidsLimit).toBe(256);
|
||||
});
|
||||
|
||||
it("hardens the container", () => {
|
||||
const options = buildSandboxContainerOptions(spec);
|
||||
expect(options.HostConfig?.CapDrop).toEqual(["ALL"]);
|
||||
expect(options.HostConfig?.SecurityOpt).toEqual(["no-new-privileges"]);
|
||||
expect(options.HostConfig?.RestartPolicy).toEqual({ Name: "no" });
|
||||
expect(options.HostConfig?.LogConfig?.Config?.["max-size"]).toBe("10m");
|
||||
expect(options.User).toBe("1000:1000");
|
||||
});
|
||||
|
||||
it("keeps the container alive with a portable command under an init", () => {
|
||||
const options = buildSandboxContainerOptions(spec);
|
||||
expect(options.Entrypoint).toEqual([""]);
|
||||
expect(options.Cmd).toEqual(["tail", "-f", "/dev/null"]);
|
||||
expect(options.HostConfig?.Init).toBe(true);
|
||||
expect(options.WorkingDir).toBe("/home/user");
|
||||
});
|
||||
|
||||
it("sets the labels used by the reaper and reconcile", () => {
|
||||
const options = buildSandboxContainerOptions(spec);
|
||||
expect(options.name).toBe("dokploy-sandbox-sb_1");
|
||||
expect(options.Labels).toEqual({
|
||||
"dokploy.sandbox": "true",
|
||||
"dokploy.sandboxId": "sb_1",
|
||||
"dokploy.projectId": "proj_1",
|
||||
"dokploy.environmentId": "env_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("selects the isolated or internet network and never dokploy-network", () => {
|
||||
expect(buildSandboxContainerOptions(spec).HostConfig?.NetworkMode).toBe(
|
||||
SANDBOX_ISOLATED_NETWORK,
|
||||
);
|
||||
expect(
|
||||
buildSandboxContainerOptions({ ...spec, networkMode: "internet" })
|
||||
.HostConfig?.NetworkMode,
|
||||
).toBe(SANDBOX_INTERNET_NETWORK);
|
||||
expect(getSandboxNetworkName("isolated")).not.toBe("dokploy-network");
|
||||
expect(getSandboxNetworkName("internet")).not.toBe("dokploy-network");
|
||||
});
|
||||
|
||||
it("passes env vars and HOME, skipping comments and malformed lines", () => {
|
||||
const options = buildSandboxContainerOptions(spec);
|
||||
expect(options.Env).toEqual(["HOME=/home/user", "FOO=bar", "BAZ=qux=1"]);
|
||||
});
|
||||
|
||||
it("omits User when the image default should be used", () => {
|
||||
const options = buildSandboxContainerOptions({ ...spec, user: null });
|
||||
expect(options).not.toHaveProperty("User");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSandboxEnvVars", () => {
|
||||
it("returns an empty list for empty input", () => {
|
||||
expect(parseSandboxEnvVars(null)).toEqual([]);
|
||||
expect(parseSandboxEnvVars("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSandboxUser", () => {
|
||||
it("parses uid:gid", () => {
|
||||
expect(parseSandboxUser("1000:1000")).toEqual({ uid: 1000, gid: 1000 });
|
||||
expect(parseSandboxUser("1001")).toEqual({ uid: 1001, gid: 1001 });
|
||||
});
|
||||
|
||||
it("rejects named users", () => {
|
||||
expect(parseSandboxUser("node")).toBeNull();
|
||||
expect(parseSandboxUser(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,236 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import {
|
||||
buildSandboxKillCommand,
|
||||
runSandboxExec,
|
||||
SANDBOX_EXEC_MARKER_ENV,
|
||||
SANDBOX_EXEC_TIMEOUT_EXIT_CODE,
|
||||
toSandboxEnvArray,
|
||||
} from "@dokploy/server/utils/sandbox/exec";
|
||||
import { parse, quote } from "shell-quote";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Docker multiplexes stdout/stderr on a non-tty exec as 8-byte frames:
|
||||
// [stream type, 0, 0, 0, size (BE uint32)] followed by the payload.
|
||||
const frame = (type: 1 | 2, text: string) => {
|
||||
const payload = Buffer.from(text);
|
||||
const header = Buffer.alloc(8);
|
||||
header[0] = type;
|
||||
header.writeUInt32BE(payload.length, 4);
|
||||
return Buffer.concat([header, payload]);
|
||||
};
|
||||
|
||||
// Mirrors docker-modem's demuxStream so the runner is exercised end to end.
|
||||
const demuxStream = (
|
||||
stream: NodeJS.ReadableStream,
|
||||
stdout: NodeJS.WritableStream,
|
||||
stderr: NodeJS.WritableStream,
|
||||
) => {
|
||||
let buffer = Buffer.alloc(0);
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
while (buffer.length >= 8) {
|
||||
const size = buffer.readUInt32BE(4);
|
||||
if (buffer.length < 8 + size) break;
|
||||
const payload = buffer.subarray(8, 8 + size);
|
||||
(buffer[0] === 2 ? stderr : stdout).write(payload);
|
||||
buffer = buffer.subarray(8 + size);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const createDocker = (opts: {
|
||||
frames: Buffer[];
|
||||
exitCode: number | null;
|
||||
endAfterMs?: number;
|
||||
dieOnKill?: boolean;
|
||||
}) => {
|
||||
const stream = new PassThrough();
|
||||
const execCreate = vi.fn();
|
||||
const execStart = vi.fn();
|
||||
const kill = vi.fn(async () => {
|
||||
stream.end();
|
||||
});
|
||||
const start = vi.fn(async () => undefined);
|
||||
const killExecStart = vi.fn(async () => {
|
||||
if (opts.dieOnKill) stream.end();
|
||||
});
|
||||
const inspect = vi.fn(async () => ({ ExitCode: opts.exitCode }));
|
||||
|
||||
const exec = vi.fn(async (options: { Cmd: string[] }) => {
|
||||
execCreate(options);
|
||||
if (options.Cmd[2]?.startsWith("if grep -qzF x /dev/null")) {
|
||||
return { start: killExecStart, inspect };
|
||||
}
|
||||
return {
|
||||
start: vi.fn(async (startOptions: unknown) => {
|
||||
execStart(startOptions);
|
||||
setTimeout(() => {
|
||||
for (const chunk of opts.frames) stream.write(chunk);
|
||||
if (opts.endAfterMs === undefined) stream.end();
|
||||
else if (opts.endAfterMs > 0)
|
||||
setTimeout(() => stream.end(), opts.endAfterMs);
|
||||
}, 5);
|
||||
return stream;
|
||||
}),
|
||||
inspect,
|
||||
};
|
||||
});
|
||||
|
||||
const docker = {
|
||||
getContainer: vi.fn(() => ({ exec, kill, start })),
|
||||
modem: { demuxStream },
|
||||
};
|
||||
return { docker, execCreate, execStart, kill, start, killExecStart, inspect };
|
||||
};
|
||||
|
||||
describe("runSandboxExec", () => {
|
||||
it("demuxes stdout/stderr and reports the exit code", async () => {
|
||||
const { docker, execCreate, execStart } = createDocker({
|
||||
frames: [frame(1, "hello "), frame(1, "world\n"), frame(2, "oops\n")],
|
||||
exitCode: 3,
|
||||
});
|
||||
const stdoutChunks: string[] = [];
|
||||
const result = await runSandboxExec(docker as never, "ctr", {
|
||||
cmd: "echo hello world; echo oops >&2; exit 3",
|
||||
cwd: "/home/user",
|
||||
env: { FOO: "bar" },
|
||||
timeoutMs: 5000,
|
||||
onStdout: (chunk) => stdoutChunks.push(chunk),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
stdout: "hello world\n",
|
||||
stderr: "oops\n",
|
||||
exitCode: 3,
|
||||
timedOut: false,
|
||||
truncated: false,
|
||||
containerKilled: false,
|
||||
});
|
||||
expect(stdoutChunks.join("")).toBe("hello world\n");
|
||||
|
||||
const options = execCreate.mock.calls[0]?.[0];
|
||||
expect(options.Cmd).toEqual([
|
||||
"sh",
|
||||
"-c",
|
||||
"echo hello world; echo oops >&2; exit 3",
|
||||
]);
|
||||
expect(options.WorkingDir).toBe("/home/user");
|
||||
expect(options.Tty).toBe(false);
|
||||
expect(options.Env).toContain("FOO=bar");
|
||||
expect(
|
||||
options.Env.some((e: string) =>
|
||||
e.startsWith(`${SANDBOX_EXEC_MARKER_ENV}=`),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(execStart).toHaveBeenCalledWith({ hijack: true, stdin: false });
|
||||
});
|
||||
|
||||
it("treats a null exit code as failure", async () => {
|
||||
const { docker } = createDocker({ frames: [], exitCode: null });
|
||||
const result = await runSandboxExec(docker as never, "ctr", {
|
||||
cmd: "true",
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("truncates output beyond maxOutputBytes but keeps streaming", async () => {
|
||||
const { docker } = createDocker({
|
||||
frames: [frame(1, "a".repeat(100)), frame(1, "b".repeat(100))],
|
||||
exitCode: 0,
|
||||
});
|
||||
const streamed: string[] = [];
|
||||
const result = await runSandboxExec(docker as never, "ctr", {
|
||||
cmd: "yes",
|
||||
timeoutMs: 5000,
|
||||
maxOutputBytes: 150,
|
||||
onStdout: (chunk) => streamed.push(chunk),
|
||||
});
|
||||
expect(result.stdout).toHaveLength(150);
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(streamed.join("")).toHaveLength(200);
|
||||
});
|
||||
|
||||
it("kills the process on timeout and returns exit code 124", async () => {
|
||||
const { docker, killExecStart, kill } = createDocker({
|
||||
frames: [frame(1, "partial")],
|
||||
exitCode: 0,
|
||||
endAfterMs: 0,
|
||||
dieOnKill: true,
|
||||
});
|
||||
const result = await runSandboxExec(docker as never, "ctr", {
|
||||
cmd: "sleep 100",
|
||||
timeoutMs: 50,
|
||||
});
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.exitCode).toBe(SANDBOX_EXEC_TIMEOUT_EXIT_CODE);
|
||||
expect(result.stdout).toBe("partial");
|
||||
expect(killExecStart).toHaveBeenCalledWith({ Detach: true });
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
expect(result.containerKilled).toBe(false);
|
||||
expect(result.containerRestarted).toBe(false);
|
||||
});
|
||||
|
||||
it("kills and restarts the container when the process survives the marker kill", async () => {
|
||||
const { docker, kill, start } = createDocker({
|
||||
frames: [],
|
||||
exitCode: 0,
|
||||
endAfterMs: 0,
|
||||
dieOnKill: false,
|
||||
});
|
||||
const result = await runSandboxExec(docker as never, "ctr", {
|
||||
cmd: "sleep 100",
|
||||
timeoutMs: 50,
|
||||
});
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.exitCode).toBe(SANDBOX_EXEC_TIMEOUT_EXIT_CODE);
|
||||
expect(kill).toHaveBeenCalledWith({ signal: "SIGKILL" });
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(result.containerKilled).toBe(true);
|
||||
expect(result.containerRestarted).toBe(true);
|
||||
}, 10_000);
|
||||
|
||||
it("reports a failed restart so the sandbox can be marked as error", async () => {
|
||||
const { docker, start } = createDocker({
|
||||
frames: [],
|
||||
exitCode: 0,
|
||||
endAfterMs: 0,
|
||||
dieOnKill: false,
|
||||
});
|
||||
start.mockRejectedValueOnce(new Error("no such container"));
|
||||
const result = await runSandboxExec(docker as never, "ctr", {
|
||||
cmd: "sleep 100",
|
||||
timeoutMs: 50,
|
||||
});
|
||||
expect(result.containerKilled).toBe(true);
|
||||
expect(result.containerRestarted).toBe(false);
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
describe("buildSandboxKillCommand", () => {
|
||||
it("matches the marker as a fixed string and never fails the shell", () => {
|
||||
const cmd = buildSandboxKillCommand("abc-123");
|
||||
expect(cmd).toContain(`grep -qzxF ${quote(["DOKPLOY_EXEC_ID=abc-123"])}`);
|
||||
expect(cmd).toContain(
|
||||
`tr '\\0' '\\n' < "$f" 2>/dev/null | grep -qxF ${quote(["DOKPLOY_EXEC_ID=abc-123"])}`,
|
||||
);
|
||||
expect(parse(cmd)).toContain("DOKPLOY_EXEC_ID=abc-123");
|
||||
expect(cmd).toContain("kill -9");
|
||||
expect(cmd.endsWith("; true")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toSandboxEnvArray", () => {
|
||||
it("converts records and passes arrays through", () => {
|
||||
expect(toSandboxEnvArray({ A: "1", B: "x=y" })).toEqual(["A=1", "B=x=y"]);
|
||||
expect(toSandboxEnvArray(["C=3"])).toEqual(["C=3"]);
|
||||
expect(toSandboxEnvArray(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventEmitter sanity", () => {
|
||||
it("keeps the fake stream API compatible with what the runner uses", () => {
|
||||
expect(new PassThrough()).toBeInstanceOf(EventEmitter);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import {
|
||||
buildSandboxFindCommand,
|
||||
buildSandboxLsCommand,
|
||||
parseSandboxFindOutput,
|
||||
parseSandboxLsOutput,
|
||||
} from "@dokploy/server/utils/sandbox/files";
|
||||
import { parse } from "shell-quote";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("list files commands", () => {
|
||||
it("quotes the path against shell injection", () => {
|
||||
expect(buildSandboxFindCommand("/tmp/a b; rm -rf /")).toContain(
|
||||
"'/tmp/a b; rm -rf /'",
|
||||
);
|
||||
expect(parse(buildSandboxLsCommand("/tmp/$(id)"))).toEqual([
|
||||
"ls",
|
||||
"-1Ap",
|
||||
"/tmp/$(id)",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSandboxFindOutput", () => {
|
||||
it("parses type, size, mtime, mode and name, directories first", () => {
|
||||
const out = [
|
||||
"f\t12\t1700000000.5000\t644\tmain.py",
|
||||
"d\t4096\t1700000100.0000\t755\tsrc",
|
||||
"l\t7\t1700000200.0000\t777\tlink",
|
||||
"f\t0\t1700000300.0000\t600\tname with\tspaces",
|
||||
].join("\n");
|
||||
const entries = parseSandboxFindOutput(out);
|
||||
expect(entries.map((e) => e.name)).toEqual([
|
||||
"src",
|
||||
"link",
|
||||
"main.py",
|
||||
"name with\tspaces",
|
||||
]);
|
||||
expect(entries[0]).toEqual({
|
||||
name: "src",
|
||||
type: "directory",
|
||||
size: 4096,
|
||||
mode: "755",
|
||||
modifiedAt: new Date(1700000100 * 1000).toISOString(),
|
||||
});
|
||||
expect(entries[2]).toMatchObject({
|
||||
type: "file",
|
||||
size: 12,
|
||||
modifiedAt: new Date(1700000000500).toISOString(),
|
||||
});
|
||||
expect(entries[1]?.type).toBe("symlink");
|
||||
});
|
||||
|
||||
it("returns an empty list for an empty directory", () => {
|
||||
expect(parseSandboxFindOutput("")).toEqual([]);
|
||||
expect(parseSandboxFindOutput("\n")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSandboxLsOutput", () => {
|
||||
it("marks trailing-slash entries as directories", () => {
|
||||
const entries = parseSandboxLsOutput("b.txt\na/\n.hidden\n");
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
name: "a",
|
||||
type: "directory",
|
||||
size: null,
|
||||
mode: null,
|
||||
modifiedAt: null,
|
||||
},
|
||||
{
|
||||
name: ".hidden",
|
||||
type: "file",
|
||||
size: null,
|
||||
mode: null,
|
||||
modifiedAt: null,
|
||||
},
|
||||
{ name: "b.txt", type: "file", size: null, mode: null, modifiedAt: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
|
||||
getRemoteDocker: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
ensureSandboxNetwork,
|
||||
SANDBOX_NETWORK_OPTIONS,
|
||||
} from "@dokploy/server/services/sandbox";
|
||||
|
||||
const notFound = () =>
|
||||
Object.assign(new Error("no such network"), { statusCode: 404 });
|
||||
|
||||
const createDocker = (inspect: () => Promise<unknown>) => {
|
||||
const remove = vi.fn(async () => undefined);
|
||||
const createNetwork = vi.fn(async () => undefined);
|
||||
const getNetwork = vi.fn(() => ({ inspect, remove }));
|
||||
return {
|
||||
docker: { getNetwork, createNetwork },
|
||||
remove,
|
||||
createNetwork,
|
||||
getNetwork,
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("ensureSandboxNetwork", () => {
|
||||
it("creates an internal bridge without inter-container traffic for isolated mode", async () => {
|
||||
const { docker, createNetwork } = createDocker(async () => {
|
||||
throw notFound();
|
||||
});
|
||||
const name = await ensureSandboxNetwork(docker as never, "isolated");
|
||||
expect(name).toBe("dokploy-sandboxes-isolated");
|
||||
expect(createNetwork).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
Name: "dokploy-sandboxes-isolated",
|
||||
Driver: "bridge",
|
||||
Internal: true,
|
||||
Options: SANDBOX_NETWORK_OPTIONS,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
SANDBOX_NETWORK_OPTIONS["com.docker.network.bridge.enable_icc"],
|
||||
).toBe("false");
|
||||
});
|
||||
|
||||
it("creates a non-internal bridge for internet mode", async () => {
|
||||
const { docker, createNetwork } = createDocker(async () => {
|
||||
throw notFound();
|
||||
});
|
||||
await ensureSandboxNetwork(docker as never, "internet");
|
||||
expect(createNetwork).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
Name: "dokploy-sandboxes",
|
||||
Internal: false,
|
||||
Options: SANDBOX_NETWORK_OPTIONS,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses a network that already blocks inter-container traffic", async () => {
|
||||
const { docker, createNetwork, remove } = createDocker(async () => ({
|
||||
Options: SANDBOX_NETWORK_OPTIONS,
|
||||
Containers: {},
|
||||
}));
|
||||
await ensureSandboxNetwork(docker as never, "isolated");
|
||||
expect(createNetwork).not.toHaveBeenCalled();
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recreates an unused legacy network that allows inter-container traffic", async () => {
|
||||
const { docker, createNetwork, remove } = createDocker(async () => ({
|
||||
Options: {},
|
||||
Containers: {},
|
||||
}));
|
||||
await ensureSandboxNetwork(docker as never, "isolated");
|
||||
expect(remove).toHaveBeenCalledTimes(1);
|
||||
expect(createNetwork).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps a legacy network that still has containers attached and warns", async () => {
|
||||
const { docker, createNetwork, remove } = createDocker(async () => ({
|
||||
Options: {},
|
||||
Containers: { abc: { Name: "dokploy-sandbox-x" } },
|
||||
}));
|
||||
await ensureSandboxNetwork(docker as never, "isolated");
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
expect(createNetwork).not.toHaveBeenCalled();
|
||||
expect(console.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tolerates a concurrent create of the same network", async () => {
|
||||
const { docker, createNetwork } = createDocker(async () => {
|
||||
throw notFound();
|
||||
});
|
||||
createNetwork.mockRejectedValueOnce(
|
||||
Object.assign(new Error("exists"), { statusCode: 409 }),
|
||||
);
|
||||
await expect(
|
||||
ensureSandboxNetwork(docker as never, "isolated"),
|
||||
).resolves.toBe("dokploy-sandboxes-isolated");
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findMany = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: { query: { sandboxes: { findMany } } },
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/sandbox", () => ({
|
||||
killSandbox: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
findExpiredSandboxes,
|
||||
planSandboxReconcile,
|
||||
reapExpiredSandboxes,
|
||||
} from "@dokploy/server/utils/sandbox/reaper";
|
||||
|
||||
const now = new Date("2026-09-10T12:00:00Z");
|
||||
const past = new Date(now.getTime() - 1000);
|
||||
const future = new Date(now.getTime() + 60_000);
|
||||
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
});
|
||||
|
||||
describe("findExpiredSandboxes", () => {
|
||||
it("returns only running sandboxes whose expiresAt has passed", () => {
|
||||
const list = [
|
||||
{ sandboxId: "expired", status: "running", expiresAt: past },
|
||||
{ sandboxId: "alive", status: "running", expiresAt: future },
|
||||
{ sandboxId: "no-expiry", status: "running", expiresAt: null },
|
||||
{ sandboxId: "killed", status: "killed", expiresAt: past },
|
||||
{ sandboxId: "error", status: "error", expiresAt: past },
|
||||
];
|
||||
expect(findExpiredSandboxes(list, now).map((s) => s.sandboxId)).toEqual([
|
||||
"expired",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reapExpiredSandboxes", () => {
|
||||
it("kills expired sandboxes and leaves the rest untouched", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{ sandboxId: "expired", status: "running", expiresAt: past },
|
||||
{ sandboxId: "alive", status: "running", expiresAt: future },
|
||||
]);
|
||||
const kill = vi.fn(async () => undefined);
|
||||
const killed = await reapExpiredSandboxes(now, kill);
|
||||
expect(killed).toEqual(["expired"]);
|
||||
expect(kill).toHaveBeenCalledTimes(1);
|
||||
expect(kill).toHaveBeenCalledWith("expired");
|
||||
});
|
||||
|
||||
it("continues when one kill fails", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{ sandboxId: "a", status: "running", expiresAt: past },
|
||||
{ sandboxId: "b", status: "running", expiresAt: past },
|
||||
]);
|
||||
const kill = vi.fn(async (id: string) => {
|
||||
if (id === "a") throw new Error("docker down");
|
||||
});
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const killed = await reapExpiredSandboxes(now, kill);
|
||||
spy.mockRestore();
|
||||
expect(killed).toEqual(["b"]);
|
||||
expect(kill).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planSandboxReconcile", () => {
|
||||
const container = (id: string, sandboxId: string, state = "running") => ({
|
||||
Id: id,
|
||||
State: state,
|
||||
Labels: { "dokploy.sandbox": "true", "dokploy.sandboxId": sandboxId },
|
||||
});
|
||||
|
||||
it("removes containers without a running row and errors rows without a container", () => {
|
||||
const plan = planSandboxReconcile(
|
||||
[
|
||||
{ sandboxId: "ok", containerId: "c-ok", status: "running" },
|
||||
{ sandboxId: "gone", containerId: "c-gone", status: "running" },
|
||||
{ sandboxId: "never-started", containerId: null, status: "running" },
|
||||
],
|
||||
[
|
||||
container("c-ok", "ok"),
|
||||
container("c-orphan", "orphan"),
|
||||
container("c-exited", "ok-old", "exited"),
|
||||
],
|
||||
);
|
||||
expect(plan.removeContainers).toEqual(["c-orphan", "c-exited"]);
|
||||
expect(plan.markError).toEqual(["gone", "never-started"]);
|
||||
});
|
||||
|
||||
it("removes a stopped container even when its row is running, and errors the row", () => {
|
||||
const plan = planSandboxReconcile(
|
||||
[{ sandboxId: "sb", containerId: "c1", status: "running" }],
|
||||
[container("c1", "sb", "exited")],
|
||||
);
|
||||
expect(plan.removeContainers).toEqual(["c1"]);
|
||||
expect(plan.markError).toEqual(["sb"]);
|
||||
});
|
||||
|
||||
it("leaves containers of sandboxes that are still being created alone", () => {
|
||||
const plan = planSandboxReconcile(
|
||||
[{ sandboxId: "new", containerId: null, status: "creating" }],
|
||||
[container("c-new", "new")],
|
||||
);
|
||||
expect(plan).toEqual({ removeContainers: [], markError: [] });
|
||||
});
|
||||
|
||||
it("does nothing when everything matches", () => {
|
||||
const plan = planSandboxReconcile(
|
||||
[{ sandboxId: "sb", containerId: "c1", status: "running" }],
|
||||
[container("c1", "sb")],
|
||||
);
|
||||
expect(plan).toEqual({ removeContainers: [], markError: [] });
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
import { Readable } from "node:stream";
|
||||
import {
|
||||
buildSandboxDirectoryTar,
|
||||
buildSandboxFileTar,
|
||||
extractSandboxFile,
|
||||
SandboxFileTooLargeError,
|
||||
SandboxNotRegularFileError,
|
||||
} from "@dokploy/server/utils/sandbox/tar";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type Entry = {
|
||||
name: string;
|
||||
type: string;
|
||||
mode: number;
|
||||
uid: number;
|
||||
gid: number;
|
||||
size: number;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const TYPES: Record<string, string> = {
|
||||
"0": "file",
|
||||
"\0": "file",
|
||||
"5": "directory",
|
||||
};
|
||||
|
||||
// Minimal ustar reader: 512-byte header blocks with octal numeric fields.
|
||||
const readTar = (buffer: Buffer): Entry[] => {
|
||||
const entries: Entry[] = [];
|
||||
let offset = 0;
|
||||
const field = (start: number, length: number) =>
|
||||
buffer
|
||||
.subarray(offset + start, offset + start + length)
|
||||
.toString("utf8")
|
||||
.replace(/\0.*$/s, "");
|
||||
const octal = (start: number, length: number) =>
|
||||
Number.parseInt(field(start, length).trim() || "0", 8);
|
||||
while (offset + 512 <= buffer.length) {
|
||||
const name = field(0, 100);
|
||||
if (!name) break;
|
||||
const size = octal(124, 12);
|
||||
const typeflag = buffer[offset + 156] ?? 0;
|
||||
entries.push({
|
||||
name,
|
||||
type: TYPES[String.fromCharCode(typeflag)] ?? "other",
|
||||
mode: octal(100, 8),
|
||||
uid: octal(108, 8),
|
||||
gid: octal(116, 8),
|
||||
size,
|
||||
content: buffer
|
||||
.subarray(offset + 512, offset + 512 + size)
|
||||
.toString("utf8"),
|
||||
});
|
||||
offset += 512 + Math.ceil(size / 512) * 512;
|
||||
}
|
||||
return entries;
|
||||
};
|
||||
|
||||
describe("buildSandboxFileTar", () => {
|
||||
it("packs a single file with the requested ownership and mode", async () => {
|
||||
const tar = await buildSandboxFileTar({
|
||||
name: "main.py",
|
||||
content: Buffer.from("print('hi')\n"),
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
mode: 0o600,
|
||||
});
|
||||
const entries = await readTar(tar);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toMatchObject({
|
||||
name: "main.py",
|
||||
type: "file",
|
||||
mode: 0o600,
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
size: 12,
|
||||
content: "print('hi')\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to root ownership and 0644", async () => {
|
||||
const entries = await readTar(
|
||||
await buildSandboxFileTar({ name: "a.txt", content: Buffer.alloc(0) }),
|
||||
);
|
||||
expect(entries[0]).toMatchObject({ mode: 0o644, uid: 0, gid: 0, size: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSandboxDirectoryTar", () => {
|
||||
it("packs a directory entry with a trailing slash", async () => {
|
||||
const entries = await readTar(
|
||||
await buildSandboxDirectoryTar({ name: "user", uid: 1000, gid: 1000 }),
|
||||
);
|
||||
expect(entries[0]).toMatchObject({
|
||||
name: "user/",
|
||||
type: "directory",
|
||||
mode: 0o755,
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSandboxFile", () => {
|
||||
it("extracts the first regular file", async () => {
|
||||
const tar = await buildSandboxFileTar({
|
||||
name: "out.txt",
|
||||
content: Buffer.from("hello"),
|
||||
});
|
||||
const file = await extractSandboxFile(Readable.from(tar), 1024);
|
||||
expect(file.name).toBe("out.txt");
|
||||
expect(file.content.toString()).toBe("hello");
|
||||
});
|
||||
|
||||
it("rejects files over the limit", async () => {
|
||||
const tar = await buildSandboxFileTar({
|
||||
name: "big.bin",
|
||||
content: Buffer.alloc(2048, 1),
|
||||
});
|
||||
await expect(
|
||||
extractSandboxFile(Readable.from(tar), 1024),
|
||||
).rejects.toBeInstanceOf(SandboxFileTooLargeError);
|
||||
});
|
||||
|
||||
it("rejects a directory archive even when it contains files", async () => {
|
||||
const dir = await buildSandboxDirectoryTar({ name: "proj" });
|
||||
const file = await buildSandboxFileTar({
|
||||
name: "proj/main.py",
|
||||
content: Buffer.from("print(1)"),
|
||||
});
|
||||
// tar-stream pads each archive with two zero blocks; strip the first
|
||||
// archive's end-of-archive marker so the entries are read as one tar.
|
||||
const combined = Buffer.concat([dir.subarray(0, dir.length - 1024), file]);
|
||||
await expect(
|
||||
extractSandboxFile(Readable.from(combined), 1024),
|
||||
).rejects.toBeInstanceOf(SandboxNotRegularFileError);
|
||||
});
|
||||
|
||||
it("rejects archives without a regular file", async () => {
|
||||
const tar = await buildSandboxDirectoryTar({ name: "dir" });
|
||||
await expect(
|
||||
extractSandboxFile(Readable.from(tar), 1024),
|
||||
).rejects.toBeInstanceOf(SandboxNotRegularFileError);
|
||||
});
|
||||
});
|
||||
@@ -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,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"]);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,45 @@ import { api } from "@/utils/api";
|
||||
|
||||
// Railpack versions from https://github.com/railwayapp/railpack/releases
|
||||
export const RAILPACK_VERSIONS = [
|
||||
"0.39.0",
|
||||
"0.38.0",
|
||||
"0.37.1",
|
||||
"0.37.0",
|
||||
"0.36.4",
|
||||
"0.36.3",
|
||||
"0.36.2",
|
||||
"0.36.1",
|
||||
"0.36.0",
|
||||
"0.35.0",
|
||||
"0.34.0",
|
||||
"0.33.0",
|
||||
"0.32.0",
|
||||
"0.31.2",
|
||||
"0.31.1",
|
||||
"0.31.0",
|
||||
"0.30.1",
|
||||
"0.30.0",
|
||||
"0.29.0",
|
||||
"0.28.0",
|
||||
"0.27.2",
|
||||
"0.27.1",
|
||||
"0.27.0",
|
||||
"0.26.1",
|
||||
"0.26.0",
|
||||
"0.25.0",
|
||||
"0.24.0",
|
||||
"0.23.0",
|
||||
"0.22.2",
|
||||
"0.22.1",
|
||||
"0.22.0",
|
||||
"0.21.0",
|
||||
"0.20.0",
|
||||
"0.19.0",
|
||||
"0.18.0",
|
||||
"0.17.2",
|
||||
"0.17.1",
|
||||
"0.17.0",
|
||||
"0.16.0",
|
||||
"0.15.4",
|
||||
"0.15.3",
|
||||
"0.15.2",
|
||||
@@ -45,12 +84,22 @@ export const RAILPACK_VERSIONS = [
|
||||
"0.9.1",
|
||||
"0.9.0",
|
||||
"0.8.0",
|
||||
"0.7.2",
|
||||
"0.7.1",
|
||||
"0.7.0",
|
||||
"0.6.1",
|
||||
"0.6.0",
|
||||
"0.5.1",
|
||||
"0.5.0",
|
||||
"0.4.0",
|
||||
"0.3.0",
|
||||
"0.2.3",
|
||||
"0.2.2",
|
||||
"0.2.1",
|
||||
"0.2.0",
|
||||
"0.1.2",
|
||||
"0.1.1",
|
||||
"0.1.0",
|
||||
] as const;
|
||||
|
||||
export enum BuildType {
|
||||
|
||||
@@ -84,9 +84,9 @@ export const ShowPatches = ({ id, type }: Props) => {
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Patches</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl">Patches</CardTitle>
|
||||
<CardDescription>
|
||||
Apply code patches to your repository during build. Patches are
|
||||
applied after cloning the repository and before building.
|
||||
|
||||
@@ -77,23 +77,19 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
||||
<CardHeader className="px-0">
|
||||
<div className="flex justify-between items-center gap-y-2 flex-wrap">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||
Scheduled Tasks
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Schedule tasks to run automatically at specified intervals.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{schedules && schedules.length > 0 && (
|
||||
<HandleSchedules id={id} scheduleType={scheduleType} />
|
||||
)}
|
||||
<Card className="bg-background h-full min-h-[50vh]">
|
||||
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl">Scheduled Tasks</CardTitle>
|
||||
<CardDescription>
|
||||
Schedule tasks to run automatically at specified intervals.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{schedules && schedules.length > 0 && (
|
||||
<HandleSchedules id={id} scheduleType={scheduleType} />
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
<CardContent>
|
||||
{isLoadingSchedules ? (
|
||||
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||
|
||||
+18
-27
@@ -77,35 +77,26 @@ export const ShowVolumeBackups = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className=" px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
||||
<CardHeader className="px-0">
|
||||
<div className="flex justify-between items-center flex-wrap gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||
Volume Backups
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Schedule volume backups to run automatically at specified
|
||||
intervals
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{volumeBackups && volumeBackups.length > 0 && (
|
||||
<>
|
||||
<HandleVolumeBackups id={id} volumeBackupType={type} />
|
||||
<div className="flex items-center gap-2">
|
||||
<RestoreVolumeBackups
|
||||
id={id}
|
||||
type={type}
|
||||
serverId={serverId}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Card className="bg-background h-full min-h-[50vh]">
|
||||
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl">Volume Backups</CardTitle>
|
||||
<CardDescription>
|
||||
Schedule volume backups to run automatically at specified intervals
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{volumeBackups && volumeBackups.length > 0 && (
|
||||
<>
|
||||
<HandleVolumeBackups id={id} volumeBackupType={type} />
|
||||
<div className="flex items-center gap-2">
|
||||
<RestoreVolumeBackups id={id} type={type} serverId={serverId} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
<CardContent>
|
||||
{isLoadingVolumeBackups ? (
|
||||
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||
|
||||
@@ -6,28 +6,41 @@ import { api } from "@/utils/api";
|
||||
export const TrialBanner = () => {
|
||||
const router = useRouter();
|
||||
const { data: billingStatus } = api.stripe.getBillingStatus.useQuery();
|
||||
const { mutateAsync: createCustomerPortalSession, isPending } =
|
||||
api.stripe.createCustomerPortalSession.useMutation();
|
||||
|
||||
if (!billingStatus?.isOnTrial) {
|
||||
const handleAddPaymentMethod = async () => {
|
||||
const session = await createCustomerPortalSession().catch(() => null);
|
||||
if (session?.url) {
|
||||
window.open(session.url);
|
||||
return;
|
||||
}
|
||||
router.push("/dashboard/settings/billing");
|
||||
};
|
||||
|
||||
if (!billingStatus?.isOnTrial || billingStatus.hasPaymentMethod) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const daysRemaining = billingStatus.trialDaysRemaining ?? 0;
|
||||
const daysLabel = `${daysRemaining} day${daysRemaining === 1 ? "" : "s"}`;
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-20 mx-4 mt-4 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 rounded-lg border border-primary/20 bg-primary/10 px-4 py-2 text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<Rocket className="h-4 w-4 text-primary shrink-0" />
|
||||
{daysRemaining > 0
|
||||
? `You have ${daysRemaining} day${daysRemaining === 1 ? "" : "s"} left in your free trial.`
|
||||
: "Your free trial ends today."}
|
||||
? `You have ${daysLabel} left in your free trial. Add a payment method to keep your servers.`
|
||||
: "Your free trial ends today. Add a payment method to keep your servers."}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-7"
|
||||
onClick={() => router.push("/dashboard/settings/billing")}
|
||||
isLoading={isPending}
|
||||
onClick={handleAddPaymentMethod}
|
||||
>
|
||||
Upgrade now
|
||||
Add payment method
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
-1
@@ -158,7 +158,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
composePath: data.composePath,
|
||||
composeId,
|
||||
sourceType: "bitbucket",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
|
||||
-1
@@ -97,7 +97,6 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
composeId,
|
||||
sourceType: "git",
|
||||
composePath: values.composePath,
|
||||
composeStatus: "idle",
|
||||
watchPaths: values.watchPaths || [],
|
||||
enableSubmodules: values.enableSubmodules,
|
||||
})
|
||||
|
||||
-1
@@ -157,7 +157,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
giteaId: data.giteaId,
|
||||
composeId,
|
||||
sourceType: "gitea",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
} as any)
|
||||
|
||||
-1
@@ -155,7 +155,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
composePath: data.composePath,
|
||||
githubId: data.githubId,
|
||||
sourceType: "github",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
triggerType: data.triggerType,
|
||||
|
||||
-1
@@ -169,7 +169,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
gitlabProjectId: data.repository.id,
|
||||
gitlabPathNamespace: data.repository.gitlabPathNamespace,
|
||||
sourceType: "gitlab",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
ClipboardList,
|
||||
Database,
|
||||
DatabaseBackup,
|
||||
Play,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { ClipboardList, DatabaseBackup, Play, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -101,12 +95,9 @@ export const ShowBackups = ({
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row justify-between gap-4 flex-wrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Database className="size-6 text-muted-foreground" />
|
||||
Backups
|
||||
</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl">Backups</CardTitle>
|
||||
<CardDescription>
|
||||
Add backups to your database to save the data to a different
|
||||
provider.
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
type PaginationState,
|
||||
@@ -27,7 +25,6 @@ import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -97,14 +94,21 @@ function getServiceInfo(d: DeploymentRow) {
|
||||
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>([
|
||||
{ id: "createdAt", desc: true },
|
||||
]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 50,
|
||||
@@ -446,177 +450,140 @@ export function ShowDeploymentsTable() {
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
pagination,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search by name, project, environment, server..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="running">Running</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value="application">Application</SelectItem>
|
||||
<SelectItem value="compose">Compose</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="px-0">
|
||||
{isLoading ? (
|
||||
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Loading deployments...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
<div className="px-0">
|
||||
{isLoading ? (
|
||||
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Loading deployments...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</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>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className=" text-center"
|
||||
>
|
||||
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
|
||||
<Rocket className="size-8" />
|
||||
<p className="font-medium">No deployments found</p>
|
||||
<p className="text-sm">
|
||||
Deployments from applications and compose will
|
||||
appear here.
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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 flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Rows per page
|
||||
</span>
|
||||
<Select
|
||||
value={String(pagination.pageSize)}
|
||||
onValueChange={(value) => {
|
||||
setPagination((p) => ({
|
||||
...p,
|
||||
pageSize: Number(value),
|
||||
pageIndex: 0,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 25, 50, 100].map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Showing{" "}
|
||||
{filteredData.length === 0
|
||||
? 0
|
||||
: pagination.pageIndex * pagination.pageSize + 1}{" "}
|
||||
to{" "}
|
||||
{Math.min(
|
||||
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||
filteredData.length,
|
||||
)}{" "}
|
||||
of {filteredData.length} entries
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ export const ShowContainers = ({ serverId }: Props) => {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
<div className="gap-4 pb-20 w-full">
|
||||
<div className="flex flex-col gap-4 w-full overflow-auto">
|
||||
<div className="flex flex-col gap-4 w-full overflow-auto">
|
||||
<div className="flex items-center gap-2 max-sm:flex-wrap">
|
||||
<Input
|
||||
placeholder="Filter by name..."
|
||||
|
||||
@@ -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 { api } from "@/utils/api";
|
||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||
import { DeleteAccountByEmail } from "./delete-account-by-email";
|
||||
|
||||
type User = typeof authClient.$Infer.Session.user;
|
||||
|
||||
@@ -78,12 +79,12 @@ export const ImpersonationBar = () => {
|
||||
});
|
||||
|
||||
const filteredUsers = response.data?.users.filter(
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
(user) => user.allowImpersonation && data?.user?.email !== user.email,
|
||||
);
|
||||
|
||||
if (!response.error) {
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
setUsers(filteredUsers || []);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -282,6 +283,7 @@ export const ImpersonationBar = () => {
|
||||
<Shield className="h-4 w-4" />
|
||||
Impersonate
|
||||
</Button>
|
||||
<DeleteAccountByEmail />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4 w-full flex-wrap">
|
||||
|
||||
@@ -278,8 +278,9 @@ export const OnboardingWizard = ({ onClose }: Props) => {
|
||||
<DialogTitle>Skip onboarding?</DialogTitle>
|
||||
<DialogDescription>
|
||||
If this is your first time using Dokploy, we recommend going
|
||||
through these steps — it only takes a couple of minutes and gives
|
||||
you a feel for how projects, servers and deployments fit together.
|
||||
through these steps – it only takes a couple of minutes and gives
|
||||
you a feel for how projects, servers, and deployments fit
|
||||
together.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -15,13 +15,50 @@ const stripePromise = loadStripe(
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
|
||||
);
|
||||
|
||||
type Tier = "hobby" | "startup";
|
||||
|
||||
const PLANS: {
|
||||
tier: Tier;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
features: string[];
|
||||
recommended?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
tier: "hobby",
|
||||
name: "Hobby",
|
||||
description: "For individual developers",
|
||||
price: calculatePriceHobby(1, false),
|
||||
features: [
|
||||
"Setup 1 server",
|
||||
"Unlimited apps & databases",
|
||||
"2 environments",
|
||||
"Community support",
|
||||
],
|
||||
},
|
||||
{
|
||||
tier: "startup",
|
||||
name: "Startup",
|
||||
description: "For small to mid-size teams",
|
||||
price: calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false),
|
||||
recommended: true,
|
||||
features: [
|
||||
`Setup up to ${STARTUP_SERVERS_INCLUDED} servers`,
|
||||
"Unlimited users & environments",
|
||||
"Basic RBAC + 2FA",
|
||||
"Email & chat support",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export const PlanStep = ({ onNext }: Props) => {
|
||||
const [loadingTier, setLoadingTier] = useState<
|
||||
"hobby" | "startup" | "trial" | null
|
||||
const [loading, setLoading] = useState<
|
||||
`${"trial" | "checkout"}:${Tier}` | null
|
||||
>(null);
|
||||
const { data } = api.stripe.getProducts.useQuery();
|
||||
const { mutateAsync: createCheckoutSession } =
|
||||
@@ -30,12 +67,12 @@ export const PlanStep = ({ onNext }: Props) => {
|
||||
api.stripe.startFreeTrial.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const handleCheckout = async (tier: "hobby" | "startup") => {
|
||||
const handleCheckout = async (tier: Tier) => {
|
||||
if (!data) return;
|
||||
const productId =
|
||||
tier === "hobby" ? data.hobbyProductId : data.startupProductId;
|
||||
if (!productId) return;
|
||||
setLoadingTier(tier);
|
||||
setLoading(`checkout:${tier}`);
|
||||
try {
|
||||
const stripe = await stripePromise;
|
||||
const session = await createCheckoutSession({
|
||||
@@ -47,14 +84,14 @@ export const PlanStep = ({ onNext }: Props) => {
|
||||
await stripe?.redirectToCheckout({ sessionId: session.sessionId });
|
||||
} catch {
|
||||
toast.error("Error starting checkout");
|
||||
setLoadingTier(null);
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrial = async () => {
|
||||
setLoadingTier("trial");
|
||||
const handleTrial = async (tier: Tier) => {
|
||||
setLoading(`trial:${tier}`);
|
||||
try {
|
||||
await startFreeTrial();
|
||||
await startFreeTrial({ tier });
|
||||
await utils.project.onboardingStatus.invalidate();
|
||||
toast.success("Your 7-day trial has started");
|
||||
onNext();
|
||||
@@ -63,7 +100,7 @@ export const PlanStep = ({ onNext }: Props) => {
|
||||
error instanceof Error ? error.message : "Error starting trial",
|
||||
);
|
||||
} finally {
|
||||
setLoadingTier(null);
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -79,134 +116,72 @@ export const PlanStep = ({ onNext }: Props) => {
|
||||
Start free, upgrade when ready.
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
No credit card for the trial — add one only if you decide to stay.
|
||||
Try any plan free for 7 days. No credit card required — add one only
|
||||
if you decide to stay.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col rounded-2xl bg-zinc-950 dark:bg-white text-white dark:text-zinc-950 overflow-hidden">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-6 p-7">
|
||||
<div>
|
||||
<span className="inline-flex items-center rounded-full bg-white text-zinc-950 dark:bg-zinc-950 dark:text-white font-mono text-[10px] font-semibold uppercase tracking-[0.15em] px-2.5 py-1">
|
||||
Recommended
|
||||
</span>
|
||||
<p className={`${displayFont.className} text-2xl mt-3`}>
|
||||
7-day free trial
|
||||
</p>
|
||||
<p className="text-sm text-zinc-400 dark:text-zinc-600 mt-1 max-w-xs">
|
||||
No card required — cancel anytime.
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5 mt-4">
|
||||
{[
|
||||
"Setup 1 server",
|
||||
"Unlimited apps & databases",
|
||||
"Community support",
|
||||
].map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-center gap-2 text-sm text-zinc-300 dark:text-zinc-700"
|
||||
>
|
||||
<CheckIcon className="size-3.5 text-zinc-500 shrink-0" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-white text-zinc-950 hover:bg-zinc-200 dark:bg-zinc-950 dark:text-white dark:hover:bg-zinc-800 w-fit shrink-0 px-6"
|
||||
isLoading={loadingTier === "trial"}
|
||||
disabled={loadingTier !== null}
|
||||
onClick={handleTrial}
|
||||
>
|
||||
Start trial
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border rounded-2xl overflow-hidden border">
|
||||
<div className="flex flex-col justify-between gap-6 bg-background p-7">
|
||||
<div>
|
||||
<p className="font-medium">Hobby</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
For individual developers
|
||||
</p>
|
||||
<p className="text-3xl font-semibold mt-4 tabular-nums">
|
||||
${calculatePriceHobby(1, false).toFixed(2)}
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{" "}
|
||||
/mo
|
||||
</span>
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5 mt-4">
|
||||
{[
|
||||
"Setup 1 server",
|
||||
"Unlimited apps & databases",
|
||||
"2 environments",
|
||||
"Community support",
|
||||
].map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
{PLANS.map((plan) => {
|
||||
const productId =
|
||||
plan.tier === "hobby"
|
||||
? data?.hobbyProductId
|
||||
: data?.startupProductId;
|
||||
return (
|
||||
<div
|
||||
key={plan.tier}
|
||||
className="flex flex-col justify-between gap-6 bg-background p-7"
|
||||
>
|
||||
<div>
|
||||
{plan.recommended && (
|
||||
<span className="inline-flex items-center rounded-full bg-primary text-primary-foreground font-mono text-[10px] font-semibold uppercase tracking-[0.15em] px-2.5 py-1 mb-3">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
<p className="font-medium">{plan.name}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{plan.description}
|
||||
</p>
|
||||
<p className="text-3xl font-semibold mt-4 tabular-nums">
|
||||
${plan.price.toFixed(2)}
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{" "}
|
||||
/mo
|
||||
</span>
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5 mt-4">
|
||||
{plan.features.map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckIcon className="size-3.5 shrink-0" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
isLoading={loading === `trial:${plan.tier}`}
|
||||
disabled={loading !== null}
|
||||
onClick={() => handleTrial(plan.tier)}
|
||||
>
|
||||
<CheckIcon className="size-3.5 shrink-0" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
isLoading={loadingTier === "hobby"}
|
||||
disabled={loadingTier !== null || !data?.hobbyProductId}
|
||||
onClick={() => handleCheckout("hobby")}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-between gap-6 bg-background p-7">
|
||||
<div>
|
||||
<p className="font-medium">Startup</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
For small to mid-size teams
|
||||
</p>
|
||||
<p className="text-3xl font-semibold mt-4 tabular-nums">
|
||||
$
|
||||
{calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false).toFixed(
|
||||
2,
|
||||
)}
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{" "}
|
||||
/mo
|
||||
</span>
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5 mt-4">
|
||||
{[
|
||||
`Setup up to ${STARTUP_SERVERS_INCLUDED} servers`,
|
||||
"Unlimited users & environments",
|
||||
"Basic RBAC + 2FA",
|
||||
"Email & chat support",
|
||||
].map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
Start 7-day free trial
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
isLoading={loading === `checkout:${plan.tier}`}
|
||||
disabled={loading !== null || !productId}
|
||||
onClick={() => handleCheckout(plan.tier)}
|
||||
>
|
||||
<CheckIcon className="size-3.5 shrink-0" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
isLoading={loadingTier === "startup"}
|
||||
disabled={loadingTier !== null || !data?.startupProductId}
|
||||
onClick={() => handleCheckout("startup")}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
</div>
|
||||
Subscribe now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -65,7 +65,7 @@ export const ProjectStep = ({ onNext, plainTitle }: Props) => {
|
||||
</span>
|
||||
<h1 className={titleClassName}>Create your first project.</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
Projects group your apps, databases and environments together.
|
||||
Projects group your apps, databases, and environments together.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CheckCircle2, CopyIcon, Loader2, XCircle } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
CopyIcon,
|
||||
InfoIcon,
|
||||
Loader2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -200,9 +206,19 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {
|
||||
</span>
|
||||
<h1 className={titleClassName}>Connect a server.</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
Dokploy deploys to servers you own — buy one from any VPS provider
|
||||
Dokploy deploys to servers you own. Buy one from any VPS provider
|
||||
(Hetzner, DigitalOcean, Hostinger...) and paste its IP below.
|
||||
</p>
|
||||
<div className="flex items-start gap-2.5 rounded-lg border p-3 max-w-md">
|
||||
<InfoIcon className="size-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">Requirements</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
To ensure a smooth experience with Dokploy, your server should
|
||||
have at least 1GB of RAM and 30GB of disk space.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!createdServerId ? (
|
||||
@@ -287,7 +303,7 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {
|
||||
{isSettingUp ? (
|
||||
<div className="flex items-center justify-center gap-3 py-4 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin shrink-0" />
|
||||
Setting up your server — installing Docker and dependencies...
|
||||
Setting up your server – installing Docker and dependencies…
|
||||
</div>
|
||||
) : isValidating && !validation ? (
|
||||
<div className="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
|
||||
@@ -311,7 +327,7 @@ export const ServerStep = ({ onNext, plainTitle }: Props) => {
|
||||
</div>
|
||||
{!isReady && !isSettingUp && (
|
||||
<AlertBlock type="info">
|
||||
Setup usually takes a minute after the server boots — this checks
|
||||
Setup runs after the server boots and this page checks for it
|
||||
automatically. You can retry setup, or use "Skip for now" above to
|
||||
continue and come back to this later.
|
||||
</AlertBlock>
|
||||
|
||||
@@ -39,8 +39,8 @@ export const WelcomeStep = ({ onNext }: Props) => {
|
||||
Let's get your first app <em className="not-italic">live</em>.
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
A few steps — pick a plan, connect a server, ship something. You'll
|
||||
have a working URL by the end.
|
||||
It just takes a few steps: pick a plan, connect a server, ship
|
||||
something. You'll have a working URL by the end.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Rocket } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
|
||||
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
|
||||
import {
|
||||
@@ -8,6 +9,14 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
const SUBTAB_VALUES = ["deployments", "queue"] as const;
|
||||
@@ -26,6 +35,10 @@ export const ShowOverviewDeployments = () => {
|
||||
? router.query.subtab
|
||||
: DEFAULT_SUBTAB;
|
||||
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
|
||||
const setSubtab = (value: string) => {
|
||||
if (!isValidSubtab(value)) return;
|
||||
const { subtab: _current, ...query } = router.query;
|
||||
@@ -59,12 +72,50 @@ export const ShowOverviewDeployments = () => {
|
||||
onValueChange={setSubtab}
|
||||
className="w-full min-w-0"
|
||||
>
|
||||
<TabsList className="mt-2">
|
||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||
<TabsTrigger value="queue">Queue</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-y-4 gap-x-3 mt-2">
|
||||
<TabsList className="self-start shrink-0">
|
||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||
<TabsTrigger value="queue">Queue</TabsTrigger>
|
||||
</TabsList>
|
||||
{subtab === "deployments" && (
|
||||
<div className="flex flex-wrap lg:flex-nowrap items-center gap-2 lg:ml-auto">
|
||||
<Input
|
||||
placeholder="Search by name, project, environment, server..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="running">Running</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value="application">Application</SelectItem>
|
||||
<SelectItem value="compose">Compose</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TabsContent value="deployments" className="mt-0 min-w-0 pt-4">
|
||||
<ShowDeploymentsTable />
|
||||
<ShowDeploymentsTable
|
||||
globalFilter={globalFilter}
|
||||
statusFilter={statusFilter}
|
||||
typeFilter={typeFilter}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="queue" className="mt-0 pt-4">
|
||||
<ShowQueueTable />
|
||||
|
||||
@@ -189,7 +189,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
||||
<>
|
||||
<div className="flex flex-col gap-6 w-full ">
|
||||
<div className="mt-6 grid gap-4 pb-20 w-full">
|
||||
<div className="flex flex-col gap-4 w-full overflow-auto">
|
||||
<div className="flex flex-col gap-4 w-full overflow-auto">
|
||||
<div className="flex items-center gap-2 max-sm:flex-wrap">
|
||||
<Input
|
||||
placeholder="Filter by hostname..."
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Box, PlusIcon } 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 { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string().max(64).optional(),
|
||||
source: z.enum(["template", "image"]),
|
||||
template: z.enum(["base", "python", "node"]),
|
||||
image: z.string().max(255).optional(),
|
||||
serverId: z.string().nullable(),
|
||||
cpu: z.number().min(0.1).max(64),
|
||||
memoryMb: z.number().int().min(64).max(262_144),
|
||||
timeoutMinutes: z.number().min(1).max(1440),
|
||||
networkMode: z.enum(["isolated", "internet"]),
|
||||
envVars: z.string().optional(),
|
||||
})
|
||||
.refine((value) => value.source === "template" || !!value.image?.trim(), {
|
||||
message: "Image is required",
|
||||
path: ["image"],
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
interface Props {
|
||||
environmentId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export const AddSandbox = ({ environmentId, projectId }: Props) => {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: webServerSettings } =
|
||||
api.settings.getWebServerSettings.useQuery();
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const { data: templates } = api.sandbox.templates.useQuery();
|
||||
const { mutateAsync, isPending, error } = api.sandbox.create.useMutation();
|
||||
|
||||
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
source: "template",
|
||||
template: "python",
|
||||
image: "",
|
||||
serverId: null,
|
||||
cpu: 1,
|
||||
memoryMb: 512,
|
||||
timeoutMinutes: 5,
|
||||
networkMode: "isolated",
|
||||
envVars: "",
|
||||
},
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
const source = form.watch("source");
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const sandbox = await mutateAsync({
|
||||
environmentId,
|
||||
name: values.name?.trim() || undefined,
|
||||
template: values.source === "template" ? values.template : undefined,
|
||||
image: values.source === "image" ? values.image?.trim() : undefined,
|
||||
serverId: values.serverId,
|
||||
cpu: values.cpu,
|
||||
memoryMb: values.memoryMb,
|
||||
timeoutMs: Math.round(values.timeoutMinutes * 60_000),
|
||||
networkMode: values.networkMode,
|
||||
envVars: values.envVars?.trim() || undefined,
|
||||
});
|
||||
toast.success("Sandbox created");
|
||||
await utils.sandbox.list.invalidate({ environmentId });
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
router.push(
|
||||
`/dashboard/project/${projectId}/environment/${environmentId}/sandboxes/${sandbox.sandboxId}`,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Error creating the sandbox");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<PlusIcon className="size-4" />
|
||||
Create Sandbox
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl md:max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Box className="size-5 text-muted-foreground" />
|
||||
New Sandbox
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
An ephemeral, resource-limited container you can drive through the
|
||||
API. It is removed automatically when its timeout expires.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error && <AlertBlock type="error">{error.message}</AlertBlock>}
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="add-sandbox-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-sandbox" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="source"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Image</FormLabel>
|
||||
<Tabs value={field.value} onValueChange={field.onChange}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="template">Template</TabsTrigger>
|
||||
<TabsTrigger value="image">Custom image</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{source === "template" ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="template"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a template" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(templates ?? []).map((template) => (
|
||||
<SelectItem key={template.name} value={template.name}>
|
||||
<span className="capitalize">{template.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
· {template.image}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="image"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input placeholder="alpine:3.20" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The image must contain a POSIX shell (sh) and tail.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(servers?.length ?? 0) > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server</FormLabel>
|
||||
<Select
|
||||
value={field.value ?? "dokploy"}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === "dokploy" ? null : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{showLocalOption && (
|
||||
<SelectItem value="dokploy">
|
||||
Dokploy Server
|
||||
</SelectItem>
|
||||
)}
|
||||
{servers?.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverId}
|
||||
>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cpu"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>CPU cores</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min={0.1}
|
||||
{...field}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.valueAsNumber)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="memoryMb"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Memory (MB)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="64"
|
||||
min={64}
|
||||
{...field}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.valueAsNumber)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timeoutMinutes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Timeout (minutes)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min={1}
|
||||
{...field}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.valueAsNumber)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="networkMode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Network</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="isolated">
|
||||
Isolated (no network access)
|
||||
</SelectItem>
|
||||
<SelectItem value="internet">Internet access</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Sandboxes are never attached to dokploy-network or any
|
||||
application network.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="envVars"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Environment variables</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={"KEY=value\nOTHER=value"}
|
||||
className="font-mono min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="add-sandbox-form"
|
||||
isLoading={isPending}
|
||||
disabled={isPending}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Clock } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 schema = z.object({
|
||||
minutes: z.number().min(1).max(1440),
|
||||
});
|
||||
|
||||
interface Props {
|
||||
sandboxId: string;
|
||||
currentTimeoutMs: number;
|
||||
}
|
||||
|
||||
export const ExtendSandboxTimeout = ({
|
||||
sandboxId,
|
||||
currentTimeoutMs,
|
||||
}: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { mutateAsync, isPending } = api.sandbox.setTimeout.useMutation();
|
||||
const form = useForm<z.infer<typeof schema>>({
|
||||
defaultValues: {
|
||||
minutes: Math.max(1, Math.round(currentTimeoutMs / 60_000)),
|
||||
},
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Clock className="size-4" />
|
||||
Extend timeout
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Extend timeout</DialogTitle>
|
||||
<DialogDescription>
|
||||
The sandbox will be killed this many minutes after its last
|
||||
activity.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="extend-sandbox-form"
|
||||
className="grid gap-4"
|
||||
onSubmit={form.handleSubmit(async (values) => {
|
||||
await mutateAsync({
|
||||
sandboxId,
|
||||
timeoutMs: Math.round(values.minutes * 60_000),
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Timeout updated");
|
||||
await utils.sandbox.one.invalidate({ sandboxId });
|
||||
setOpen(false);
|
||||
})
|
||||
.catch(() => toast.error("Error updating the timeout"));
|
||||
})}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="minutes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Timeout (minutes)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
{...field}
|
||||
onChange={(event) =>
|
||||
field.onChange(event.target.valueAsNumber)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="extend-sandbox-form"
|
||||
isLoading={isPending}
|
||||
disabled={isPending}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,185 +0,0 @@
|
||||
import { Eraser, Loader2, Play, Terminal } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Line = {
|
||||
kind: "cmd" | "stdout" | "stderr" | "exit" | "error" | "info";
|
||||
text: string;
|
||||
};
|
||||
|
||||
type ServerMessage =
|
||||
| { type: "ready"; sandboxId: string }
|
||||
| { type: "stdout"; data: string }
|
||||
| { type: "stderr"; data: string }
|
||||
| { type: "exit"; exitCode: number; timedOut: boolean }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
interface Props {
|
||||
sandboxId: string;
|
||||
enabled: boolean;
|
||||
workdir: string;
|
||||
}
|
||||
|
||||
const lineClass: Record<Line["kind"], string> = {
|
||||
cmd: "text-primary font-semibold",
|
||||
stdout: "text-foreground",
|
||||
stderr: "text-orange-500",
|
||||
exit: "text-muted-foreground italic",
|
||||
error: "text-destructive",
|
||||
info: "text-muted-foreground",
|
||||
};
|
||||
|
||||
export const SandboxConsole = ({ sandboxId, enabled, workdir }: Props) => {
|
||||
const [lines, setLines] = useState<Line[]>([]);
|
||||
const [command, setCommand] = useState("");
|
||||
const [running, setRunning] = useState(false);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const outputRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const append = useCallback((line: Line) => {
|
||||
setLines((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (
|
||||
last &&
|
||||
(line.kind === "stdout" || line.kind === "stderr") &&
|
||||
last.kind === line.kind &&
|
||||
!last.text.endsWith("\n")
|
||||
) {
|
||||
return [...prev.slice(0, -1), { ...last, text: last.text + line.text }];
|
||||
}
|
||||
return [...prev, line];
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(
|
||||
`${protocol}//${window.location.host}/sandbox-exec?sandboxId=${sandboxId}`,
|
||||
);
|
||||
wsRef.current = ws;
|
||||
ws.onopen = () => setConnected(true);
|
||||
ws.onclose = (event) => {
|
||||
setConnected(false);
|
||||
setRunning(false);
|
||||
if (event.code >= 4000) {
|
||||
append({ kind: "error", text: event.reason || "Connection closed" });
|
||||
}
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
let message: ServerMessage;
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.type === "stdout")
|
||||
append({ kind: "stdout", text: message.data });
|
||||
else if (message.type === "stderr")
|
||||
append({ kind: "stderr", text: message.data });
|
||||
else if (message.type === "exit") {
|
||||
append({
|
||||
kind: "exit",
|
||||
text: message.timedOut
|
||||
? `[timed out, exit code ${message.exitCode}]`
|
||||
: `[exit code ${message.exitCode}]`,
|
||||
});
|
||||
setRunning(false);
|
||||
} else if (message.type === "error") {
|
||||
append({ kind: "error", text: message.message });
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
ws.close();
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, [sandboxId, enabled, append]);
|
||||
|
||||
useEffect(() => {
|
||||
outputRef.current?.scrollTo({ top: outputRef.current.scrollHeight });
|
||||
}, [lines]);
|
||||
|
||||
const run = () => {
|
||||
const cmd = command.trim();
|
||||
const ws = wsRef.current;
|
||||
if (!cmd || !ws || ws.readyState !== WebSocket.OPEN || running) return;
|
||||
append({ kind: "cmd", text: `$ ${cmd}` });
|
||||
setRunning(true);
|
||||
setCommand("");
|
||||
ws.send(JSON.stringify({ type: "exec", cmd }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Terminal className="size-5 text-muted-foreground" />
|
||||
Console
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{enabled
|
||||
? connected
|
||||
? "connected"
|
||||
: "connecting..."
|
||||
: "sandbox is not running"}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setLines([])}
|
||||
disabled={lines.length === 0}
|
||||
>
|
||||
<Eraser className="size-4" />
|
||||
Clear
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div
|
||||
ref={outputRef}
|
||||
className="h-[360px] overflow-auto rounded-lg border bg-muted/30 p-3 font-mono text-xs whitespace-pre-wrap break-words"
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
Commands run with `sh -c` inside {workdir}. Output streams here.
|
||||
</span>
|
||||
) : (
|
||||
lines.map((line, index) => (
|
||||
<div key={index} className={cn(lineClass[line.kind])}>
|
||||
{line.text}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
run();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={command}
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
placeholder="echo hello"
|
||||
className="font-mono"
|
||||
disabled={!enabled || !connected || running}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button type="submit" disabled={!enabled || !connected || running}>
|
||||
{running ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="size-4" />
|
||||
)}
|
||||
Run
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type SandboxStatus = "creating" | "running" | "killed" | "error";
|
||||
|
||||
const variants: Record<SandboxStatus, "green" | "yellow" | "blank" | "red"> = {
|
||||
running: "green",
|
||||
creating: "yellow",
|
||||
killed: "blank",
|
||||
error: "red",
|
||||
};
|
||||
|
||||
export const SandboxStatusBadge = ({ status }: { status: SandboxStatus }) => (
|
||||
<Badge variant={variants[status]} className="capitalize">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
@@ -1,134 +0,0 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Box, ServerIcon, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { api } from "@/utils/api";
|
||||
import { SandboxStatusBadge } from "./sandbox-status-badge";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const formatExpiresIn = (expiresAt: Date | null, status: string) => {
|
||||
if (status !== "running" || !expiresAt) return "—";
|
||||
if (expiresAt.getTime() <= Date.now()) return "expired";
|
||||
return formatDistanceToNow(expiresAt, { addSuffix: true });
|
||||
};
|
||||
|
||||
export const ShowSandboxes = ({ projectId, environmentId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { data: sandboxes, isLoading } = api.sandbox.list.useQuery(
|
||||
{ environmentId },
|
||||
{ refetchInterval: 10_000 },
|
||||
);
|
||||
const { mutateAsync: kill } = api.sandbox.kill.useMutation();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!sandboxes || sandboxes.length === 0) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] w-full flex-col items-center justify-center gap-2">
|
||||
<Box className="size-8 text-muted-foreground" />
|
||||
<span className="font-medium text-muted-foreground">
|
||||
No sandboxes yet. Create one from the UI or through the API.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Image</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Server</TableHead>
|
||||
<TableHead>CPU / RAM</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sandboxes.map((sandbox) => (
|
||||
<TableRow key={sandbox.sandboxId}>
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
href={`/dashboard/project/${projectId}/environment/${environmentId}/sandboxes/${sandbox.sandboxId}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{sandbox.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{sandbox.image}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SandboxStatusBadge status={sandbox.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<ServerIcon className="size-3.5 text-muted-foreground" />
|
||||
{sandbox.server?.name ?? "Dokploy Server"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{sandbox.cpu} vCPU / {sandbox.memoryMb} MB
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatExpiresIn(sandbox.expiresAt, sandbox.status)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<DateTooltip date={sandbox.createdAt} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{sandbox.status === "running" &&
|
||||
permissions?.deployment.create && (
|
||||
<DialogAction
|
||||
title="Kill sandbox"
|
||||
description={`The container for "${sandbox.name}" will be stopped and removed.`}
|
||||
onClick={async () => {
|
||||
await kill({ sandboxId: sandbox.sandboxId })
|
||||
.then(() => {
|
||||
toast.success("Sandbox killed");
|
||||
utils.sandbox.list.invalidate({ environmentId });
|
||||
})
|
||||
.catch(() =>
|
||||
toast.error("Error killing the sandbox"),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -24,7 +24,7 @@ export const AiForm = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row gap-2 justify-between">
|
||||
<div>
|
||||
@@ -62,7 +62,7 @@ export const AiForm = () => {
|
||||
key={config.aiId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div>
|
||||
<span className="text-sm font-medium">
|
||||
{config.name}
|
||||
|
||||
@@ -22,7 +22,7 @@ export const ShowApiKeys = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
||||
<div>
|
||||
|
||||
@@ -29,7 +29,7 @@ export const ShowBillingInvoices = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -105,13 +105,53 @@ export const ShowBilling = () => {
|
||||
api.stripe.upgradeSubscription.useMutation();
|
||||
const { mutateAsync: updateInvoiceNotifications } =
|
||||
api.stripe.updateInvoiceNotifications.useMutation();
|
||||
const { mutateAsync: startFreeTrial, isPending: isStartingTrial } =
|
||||
const { mutateAsync: startFreeTrial } =
|
||||
api.stripe.startFreeTrial.useMutation();
|
||||
const utils = api.useUtils();
|
||||
const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null);
|
||||
const [switchingTrial, setSwitchingTrial] = useState(false);
|
||||
|
||||
const handleStartTrial = async () => {
|
||||
const handleSwitchTrialPlan = async (
|
||||
tier: "hobby" | "startup",
|
||||
isAnnualPlan: boolean,
|
||||
label?: string,
|
||||
) => {
|
||||
const planName =
|
||||
label ??
|
||||
`${tier === "startup" ? "Startup" : "Hobby"} ${isAnnualPlan ? "annual" : "monthly"}`;
|
||||
|
||||
const switchPlan = async () => {
|
||||
await upgradeSubscription({
|
||||
tier,
|
||||
serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1,
|
||||
isAnnual: isAnnualPlan,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
await Promise.all([
|
||||
utils.stripe.getBillingStatus.invalidate(),
|
||||
utils.stripe.getProducts.invalidate(),
|
||||
utils.user.get.invalidate(),
|
||||
utils.server.count.invalidate(),
|
||||
]);
|
||||
};
|
||||
|
||||
setSwitchingTrial(true);
|
||||
await toast
|
||||
.promise(switchPlan(), {
|
||||
loading: `Switching to ${planName}...`,
|
||||
success: `Your trial is now on ${planName}`,
|
||||
error: (error) =>
|
||||
error instanceof Error ? error.message : "Error switching plan",
|
||||
})
|
||||
.unwrap()
|
||||
.catch(() => undefined);
|
||||
setSwitchingTrial(false);
|
||||
};
|
||||
|
||||
const handleStartTrial = async (tier: "hobby" | "startup") => {
|
||||
setTrialTier(tier);
|
||||
try {
|
||||
await startFreeTrial();
|
||||
await startFreeTrial({ tier });
|
||||
await Promise.all([
|
||||
utils.stripe.getBillingStatus.invalidate(),
|
||||
utils.stripe.getProducts.invalidate(),
|
||||
@@ -122,6 +162,8 @@ export const ShowBilling = () => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error starting trial",
|
||||
);
|
||||
} finally {
|
||||
setTrialTier(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -170,12 +212,23 @@ export const ShowBilling = () => {
|
||||
|
||||
const useNewPricing = data?.hobbyProductId && data?.startupProductId;
|
||||
const products = data?.products.filter((product) => {
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
const interval = product?.default_price?.recurring?.interval;
|
||||
return isAnnual ? interval === "year" : interval === "month";
|
||||
});
|
||||
|
||||
const isEnterpriseCloud = admin?.user.isEnterpriseCloud ?? false;
|
||||
const canStartTrial =
|
||||
!isEnterpriseCloud &&
|
||||
!!billingStatus &&
|
||||
!billingStatus.plan &&
|
||||
!billingStatus.isOnTrial &&
|
||||
!billingStatus.hasUsedTrial;
|
||||
/** Checkout would create a second subscription alongside a running trial. */
|
||||
const canSubscribe =
|
||||
!isEnterpriseCloud &&
|
||||
(data?.subscriptions?.length ?? 0) === 0 &&
|
||||
!billingStatus?.isOnTrial;
|
||||
const maxServers = admin?.user.serversQuantity ?? 1;
|
||||
const percentage = ((servers ?? 0) / maxServers) * 100;
|
||||
const safePercentage = Math.min(percentage, 100);
|
||||
@@ -289,6 +342,11 @@ export const ShowBilling = () => {
|
||||
<Badge className="capitalize" variant="secondary">
|
||||
{billingStatus.plan ?? "Trial"}
|
||||
</Badge>
|
||||
{billingStatus.plan && (
|
||||
<Badge variant="outline">
|
||||
{billingStatus.isAnnual ? "Annual" : "Monthly"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{billingStatus.isOnTrial
|
||||
@@ -296,67 +354,94 @@ export const ShowBilling = () => {
|
||||
billingStatus.trialEndsAt
|
||||
? ` · ends ${new Date(billingStatus.trialEndsAt).toLocaleDateString()}`
|
||||
: ""
|
||||
}${
|
||||
billingStatus.hasPaymentMethod
|
||||
? " · billing starts automatically"
|
||||
: " · add a card to keep your servers"
|
||||
}`
|
||||
: "You're subscribed and billed automatically."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{billingStatus.isOnTrial &&
|
||||
admin?.user.stripeCustomerId && (
|
||||
{admin?.user.stripeCustomerId && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{billingStatus.isOnTrial &&
|
||||
useNewPricing &&
|
||||
billingStatus.plan &&
|
||||
billingStatus.plan !== "legacy" && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isLoading={switchingTrial}
|
||||
onClick={() =>
|
||||
handleSwitchTrialPlan(
|
||||
billingStatus.plan === "startup"
|
||||
? "hobby"
|
||||
: "startup",
|
||||
billingStatus.isAnnual,
|
||||
)
|
||||
}
|
||||
>
|
||||
{billingStatus.plan === "startup"
|
||||
? "Switch to Hobby"
|
||||
: "Switch to Startup"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isLoading={switchingTrial}
|
||||
onClick={() =>
|
||||
handleSwitchTrialPlan(
|
||||
billingStatus.plan as "hobby" | "startup",
|
||||
!billingStatus.isAnnual,
|
||||
billingStatus.isAnnual
|
||||
? "monthly billing"
|
||||
: "annual billing",
|
||||
)
|
||||
}
|
||||
>
|
||||
{billingStatus.isAnnual
|
||||
? "Switch to monthly"
|
||||
: "Switch to annual"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
variant={
|
||||
billingStatus.isOnTrial &&
|
||||
!billingStatus.hasPaymentMethod
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
onClick={async () => {
|
||||
const session = await createCustomerPortalSession();
|
||||
window.open(session.url);
|
||||
}}
|
||||
>
|
||||
Add payment method
|
||||
{billingStatus.isOnTrial &&
|
||||
!billingStatus.hasPaymentMethod
|
||||
? "Add payment method"
|
||||
: "Manage Subscription"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isEnterpriseCloud &&
|
||||
billingStatus &&
|
||||
!billingStatus.plan &&
|
||||
!billingStatus.isOnTrial &&
|
||||
!billingStatus.hasUsedTrial && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="h-5 w-5 text-primary shrink-0" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">
|
||||
7-day free trial
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No credit card required — cancel anytime.
|
||||
</span>
|
||||
<ul className="flex flex-col gap-1 mt-1">
|
||||
{[
|
||||
"Setup 1 server",
|
||||
"Unlimited apps & databases",
|
||||
"Community support",
|
||||
].map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckIcon className="size-3.5 shrink-0" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={isStartingTrial}
|
||||
onClick={handleStartTrial}
|
||||
>
|
||||
Start trial
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canStartTrial && (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
|
||||
<Clock className="h-5 w-5 text-primary shrink-0" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
7-day free trial on any plan
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No credit card required — pick Hobby or Startup below.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
|
||||
<div className="space-y-2 flex flex-col">
|
||||
<h3 className="text-lg font-medium">Servers Plan</h3>
|
||||
@@ -933,68 +1018,71 @@ export const ShowBilling = () => {
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Servers:
|
||||
</span>
|
||||
<Button
|
||||
disabled={hobbyServerQuantity <= 1}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setHobbyServerQuantity((q) => Math.max(1, q - 1))
|
||||
}
|
||||
>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
value={hobbyServerQuantity}
|
||||
onChange={(e) =>
|
||||
setHobbyServerQuantity(
|
||||
Math.max(
|
||||
1,
|
||||
Number(
|
||||
(e.target as HTMLInputElement).value,
|
||||
) || 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setHobbyServerQuantity((q) => q + 1)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{admin?.user.stripeCustomerId && (
|
||||
{canSubscribe && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Servers:
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
const session =
|
||||
await createCustomerPortalSession();
|
||||
window.open(session.url);
|
||||
}}
|
||||
disabled={hobbyServerQuantity <= 1}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setHobbyServerQuantity((q) =>
|
||||
Math.max(1, q - 1),
|
||||
)
|
||||
}
|
||||
>
|
||||
Manage Subscription
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
value={hobbyServerQuantity}
|
||||
onChange={(e) =>
|
||||
setHobbyServerQuantity(
|
||||
Math.max(
|
||||
1,
|
||||
Number(
|
||||
(e.target as HTMLInputElement).value,
|
||||
) || 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setHobbyServerQuantity((q) => q + 1)
|
||||
}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{canStartTrial && (
|
||||
<Button
|
||||
className="w-full"
|
||||
isLoading={trialTier === "hobby"}
|
||||
disabled={trialTier !== null}
|
||||
onClick={() => handleStartTrial("hobby")}
|
||||
>
|
||||
Start 7-day free trial
|
||||
</Button>
|
||||
)}
|
||||
{canSubscribe && (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={canStartTrial ? "outline" : "default"}
|
||||
onClick={() =>
|
||||
handleCheckout("hobby", data!.hobbyProductId!)
|
||||
}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
)}
|
||||
{!isEnterpriseCloud &&
|
||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
handleCheckout("hobby", data!.hobbyProductId!)
|
||||
}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1067,85 +1155,83 @@ export const ShowBilling = () => {
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Servers (min. {STARTUP_SERVERS_INCLUDED} included)
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
disabled={
|
||||
startupServerQuantity <=
|
||||
STARTUP_SERVERS_INCLUDED
|
||||
}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() =>
|
||||
setStartupServerQuantity((q) =>
|
||||
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
|
||||
)
|
||||
}
|
||||
>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
value={startupServerQuantity}
|
||||
onChange={(e) =>
|
||||
setStartupServerQuantity(
|
||||
Math.max(
|
||||
STARTUP_SERVERS_INCLUDED,
|
||||
Number(
|
||||
(e.target as HTMLInputElement).value,
|
||||
) || STARTUP_SERVERS_INCLUDED,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="h-8 text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() =>
|
||||
setStartupServerQuantity((q) => q + 1)
|
||||
}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{admin?.user.stripeCustomerId && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
const session =
|
||||
await createCustomerPortalSession();
|
||||
window.open(session.url);
|
||||
}}
|
||||
>
|
||||
Manage Subscription
|
||||
</Button>
|
||||
)}
|
||||
{!isEnterpriseCloud &&
|
||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||
{canSubscribe && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Servers (min. {STARTUP_SERVERS_INCLUDED} included)
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
handleCheckout(
|
||||
"startup",
|
||||
data!.startupProductId!,
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
startupServerQuantity <
|
||||
startupServerQuantity <=
|
||||
STARTUP_SERVERS_INCLUDED
|
||||
}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() =>
|
||||
setStartupServerQuantity((q) =>
|
||||
Math.max(STARTUP_SERVERS_INCLUDED, q - 1),
|
||||
)
|
||||
}
|
||||
>
|
||||
Get Started
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<NumberInput
|
||||
value={startupServerQuantity}
|
||||
onChange={(e) =>
|
||||
setStartupServerQuantity(
|
||||
Math.max(
|
||||
STARTUP_SERVERS_INCLUDED,
|
||||
Number(
|
||||
(e.target as HTMLInputElement).value,
|
||||
) || STARTUP_SERVERS_INCLUDED,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="h-8 text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() =>
|
||||
setStartupServerQuantity((q) => q + 1)
|
||||
}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{canStartTrial && (
|
||||
<Button
|
||||
className="w-full"
|
||||
isLoading={trialTier === "startup"}
|
||||
disabled={trialTier !== null}
|
||||
onClick={() => handleStartTrial("startup")}
|
||||
>
|
||||
Start 7-day free trial
|
||||
</Button>
|
||||
)}
|
||||
{canSubscribe && (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={canStartTrial ? "outline" : "default"}
|
||||
onClick={() =>
|
||||
handleCheckout(
|
||||
"startup",
|
||||
data!.startupProductId!,
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
startupServerQuantity < STARTUP_SERVERS_INCLUDED
|
||||
}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1337,31 +1423,17 @@ export const ShowBilling = () => {
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mt-4 w-full">
|
||||
{admin?.user.stripeCustomerId && (
|
||||
{canSubscribe && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
const session =
|
||||
await createCustomerPortalSession();
|
||||
window.open(session.url);
|
||||
handleCheckout("legacy", product.id);
|
||||
}}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Manage Subscription
|
||||
Subscribe
|
||||
</Button>
|
||||
)}
|
||||
{!isEnterpriseCloud &&
|
||||
(data?.subscriptions?.length ?? 0) === 0 && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
handleCheckout("legacy", product.id);
|
||||
}}
|
||||
disabled={hobbyServerQuantity < 1}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -38,7 +38,7 @@ export const ShowCertificates = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
@@ -111,7 +111,7 @@ export const ShowCertificates = () => {
|
||||
key={certificate.certificateId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2 flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
|
||||
@@ -20,7 +20,7 @@ export const ShowRegistry = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
@@ -55,7 +55,7 @@ export const ShowRegistry = () => {
|
||||
key={registry.registryId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2 flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
|
||||
@@ -19,7 +19,7 @@ export const ShowDestinations = () => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
@@ -56,7 +56,7 @@ export const ShowDestinations = () => {
|
||||
key={destination.destinationId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm">
|
||||
{index + 1}. {destination.name}
|
||||
|
||||
@@ -35,7 +35,7 @@ export const ShowDnsProviders = () => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="w-full max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
|
||||
|
||||
@@ -52,7 +52,7 @@ export const ShowDnsZones = ({ dnsProviderId }: Props) => {
|
||||
api.dnsProvider.listZones.useQuery({ dnsProviderId });
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="w-full max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 p-6">
|
||||
|
||||
@@ -69,7 +69,7 @@ export const ShowGitProviders = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -31,7 +31,7 @@ export const ShowNotifications = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
@@ -70,7 +70,7 @@ export const ShowNotifications = () => {
|
||||
key={notification.notificationId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<span className="text-sm flex flex-row items-center gap-4">
|
||||
{notification.notificationType === "slack" && (
|
||||
<div className="flex items-center justify-center rounded-lg">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -150,7 +150,7 @@ export const ProfileForm = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
||||
<div>
|
||||
|
||||
+18
-20
@@ -1,6 +1,4 @@
|
||||
import { Activity } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -15,31 +13,31 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
|
||||
|
||||
interface Props {
|
||||
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);
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
{asButton ? (
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<Activity className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
{children ? (
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
}}
|
||||
>
|
||||
View Actions
|
||||
</DropdownMenuItem>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
View Actions
|
||||
</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">
|
||||
<DialogTitle className="text-xl">Web server settings</DialogTitle>
|
||||
<DialogDescription>Reload or clean the web server.</DialogDescription>
|
||||
|
||||
@@ -121,7 +121,10 @@ export const DeleteServerModal = ({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent
|
||||
className="max-w-lg"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -61,10 +61,10 @@ type Schema = z.infer<typeof Schema>;
|
||||
|
||||
interface Props {
|
||||
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 [isOpen, setIsOpen] = useState(false);
|
||||
const { data: canCreateMoreServers, refetch } =
|
||||
@@ -141,24 +141,19 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
{serverId ? (
|
||||
asButton ? (
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
) : (
|
||||
{children ? (
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
) : serverId ? (
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer "
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
}}
|
||||
>
|
||||
Edit Server
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
</DialogTrigger>
|
||||
) : (
|
||||
<DialogTrigger asChild>
|
||||
<Button className="cursor-pointer space-x-3">
|
||||
@@ -167,7 +162,10 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent className="sm:max-w-3xl ">
|
||||
<DialogContent
|
||||
className="sm:max-w-3xl "
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -35,10 +35,10 @@ import { ValidateServer } from "./validate-server";
|
||||
|
||||
interface Props {
|
||||
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 { data: server } = api.server.one.useQuery(
|
||||
{
|
||||
@@ -81,24 +81,19 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
{asButton ? (
|
||||
{children ? (
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
) : (
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<Settings className="h-4 w-4" />
|
||||
<Button className="w-full cursor-pointer " size="sm">
|
||||
Setup Server <Settings className="size-4" />
|
||||
</Button>
|
||||
</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>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
|
||||
@@ -7,19 +7,27 @@ import { ShowPaidMonitoring } from "../../monitoring/paid/servers/show-paid-moni
|
||||
interface Props {
|
||||
url: string;
|
||||
token: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ShowMonitoringModal = ({ url, token }: Props) => {
|
||||
export const ShowMonitoringModal = ({ url, token, children }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<BarChartHorizontalBigIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-7xl ">
|
||||
{children ? (
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
) : (
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<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">
|
||||
<ShowPaidMonitoring BASE_URL={url} token={token} />
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
Activity,
|
||||
BarChartHorizontalBigIcon,
|
||||
Clock,
|
||||
Key,
|
||||
KeyIcon,
|
||||
Loader2,
|
||||
Network,
|
||||
Pencil,
|
||||
ServerIcon,
|
||||
Settings,
|
||||
Terminal,
|
||||
Trash2,
|
||||
User,
|
||||
@@ -49,7 +53,7 @@ export const ShowServers = () => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{query?.success && isCloud && <WelcomeSubscription />}
|
||||
<Card className="h-full p-2.5 rounded-xl w-full">
|
||||
<Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
@@ -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 w-full">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SetupServer
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<SetupServer
|
||||
serverId={server.serverId}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="w-full cursor-pointer "
|
||||
size="sm"
|
||||
>
|
||||
Setup Server{" "}
|
||||
<Settings className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</SetupServer>
|
||||
<TooltipContent
|
||||
className="max-w-xs"
|
||||
side="bottom"
|
||||
@@ -251,22 +263,20 @@ export const ShowServers = () => {
|
||||
{server.sshKeyId &&
|
||||
permissions?.server.terminal && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<TerminalModal
|
||||
serverId={server.serverId}
|
||||
asButton={true}
|
||||
<TerminalModal
|
||||
serverId={server.serverId}
|
||||
asButton={true}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TerminalModal>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</TerminalModal>
|
||||
<TooltipContent>
|
||||
<p>Terminal</p>
|
||||
</TooltipContent>
|
||||
@@ -274,14 +284,19 @@ export const ShowServers = () => {
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<HandleServers
|
||||
serverId={server.serverId}
|
||||
asButton={true}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<HandleServers
|
||||
serverId={server.serverId}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</HandleServers>
|
||||
<TooltipContent>
|
||||
<p>Edit Server</p>
|
||||
</TooltipContent>
|
||||
@@ -289,14 +304,19 @@ export const ShowServers = () => {
|
||||
|
||||
{server.sshKeyId && !isBuildServer && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<ShowServerActions
|
||||
serverId={server.serverId}
|
||||
asButton={true}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<ShowServerActions
|
||||
serverId={server.serverId}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<Activity className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</ShowServerActions>
|
||||
<TooltipContent>
|
||||
<p>Web Server Actions</p>
|
||||
</TooltipContent>
|
||||
@@ -307,17 +327,23 @@ export const ShowServers = () => {
|
||||
server.sshKeyId &&
|
||||
!isBuildServer && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<ShowMonitoringModal
|
||||
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
|
||||
token={
|
||||
server?.metricsConfig
|
||||
?.server?.token
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<ShowMonitoringModal
|
||||
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
|
||||
token={
|
||||
server?.metricsConfig?.server
|
||||
?.token
|
||||
}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<BarChartHorizontalBigIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</ShowMonitoringModal>
|
||||
<TooltipContent>
|
||||
<p>Monitoring</p>
|
||||
</TooltipContent>
|
||||
@@ -328,22 +354,20 @@ export const ShowServers = () => {
|
||||
|
||||
{permissions?.server.delete && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<DeleteServerModal
|
||||
serverId={server.serverId}
|
||||
serverName={server.name}
|
||||
<DeleteServerModal
|
||||
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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</DeleteServerModal>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</DeleteServerModal>
|
||||
<TooltipContent>
|
||||
<p>Delete Server</p>
|
||||
</TooltipContent>
|
||||
|
||||
@@ -21,7 +21,7 @@ export const ShowDestinations = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
@@ -57,7 +57,7 @@ export const ShowDestinations = () => {
|
||||
key={sshKey.sshKeyId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
|
||||
@@ -22,7 +22,7 @@ export const TagManager = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -124,8 +124,11 @@ export const AddInvitation = () => {
|
||||
const mode = form.watch("mode");
|
||||
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
}, [form, form.formState.isSubmitSuccessful, form.reset]);
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setError(null);
|
||||
}
|
||||
}, [open, form, form.reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCloud && form.getValues("mode") === "credentials") {
|
||||
@@ -225,7 +228,7 @@ export const AddInvitation = () => {
|
||||
<FormLabel>Invite Method</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
@@ -313,7 +316,7 @@ export const AddInvitation = () => {
|
||||
<FormLabel>Email Provider</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
|
||||
@@ -320,7 +320,7 @@ export const AddUserPermissions = ({ userId, role }: Props) => {
|
||||
<form
|
||||
id="hook-form-add-permissions"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
|
||||
className="grid grid-cols-1 md:grid-cols-2 w-full gap-4"
|
||||
>
|
||||
{isCustomRole && (
|
||||
<div className="md:col-span-2 rounded-lg border p-3 bg-muted/50 text-sm text-muted-foreground">
|
||||
|
||||
@@ -40,7 +40,7 @@ export const ShowInvitations = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -52,7 +52,7 @@ export const ShowUsers = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -33,7 +33,7 @@ export const ShowVaultProviders = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -123,7 +123,7 @@ export const WebDomain = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -24,7 +24,7 @@ export const WebServer = () => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* <Card className={cn("rounded-lg w-full bg-transparent p-0", className)}></Card> */}
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -45,9 +45,8 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
||||
term.loadAddon(clipboardAddon);
|
||||
fixMacOsAltKeys(term);
|
||||
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
term.open(termRef.current);
|
||||
// @ts-ignore
|
||||
term.loadAddon(addonFit);
|
||||
addonFit.fit();
|
||||
|
||||
@@ -89,9 +88,9 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
||||
}, [id, serverId]);
|
||||
|
||||
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 id={id} ref={termRef} className="rounded-xl" />
|
||||
<div id={id} ref={termRef} className="rounded-xl h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect } from "react";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
||||
import { HubSpotWidget } from "../shared/HubSpotWidget";
|
||||
import { useHubSpotChat } from "../shared/analytics";
|
||||
import Page from "./side";
|
||||
|
||||
interface Props {
|
||||
@@ -26,6 +26,7 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
|
||||
});
|
||||
|
||||
const isChatEnabled = isCloud === true && currentPlan === "startup";
|
||||
useHubSpotChat(isChatEnabled);
|
||||
|
||||
const { data: onboardingStatus } = api.project.onboardingStatus.useQuery();
|
||||
const shouldRedirectToOnboarding =
|
||||
@@ -52,12 +53,6 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
|
||||
</Head>
|
||||
)}
|
||||
<Page>{children}</Page>
|
||||
{isChatEnabled && (
|
||||
<>
|
||||
<HubSpotWidget />
|
||||
</>
|
||||
)}
|
||||
|
||||
{haveRootAccess === true && <ImpersonationBar />}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -605,7 +605,7 @@ type CreateRoleSchema = z.infer<typeof createRoleSchema>;
|
||||
|
||||
export const ManageCustomRoles = () => {
|
||||
return (
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
return () => {
|
||||
window.hsConversationsOnReady = window.hsConversationsOnReady?.filter(
|
||||
(callback) => callback !== loadWidget,
|
||||
);
|
||||
window.HubSpotConversations?.widget.remove();
|
||||
};
|
||||
}, [enabled]);
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ const dockerComposeServices = [
|
||||
{ label: "networks", type: "keyword", info: "Define networks" },
|
||||
{ label: "configs", type: "keyword", info: "Define configuration files" },
|
||||
{ label: "secrets", type: "keyword", info: "Define secrets" },
|
||||
{ label: "models", type: "keyword", info: "Define AI models" },
|
||||
].map((opt) => ({
|
||||
...opt,
|
||||
apply: (
|
||||
@@ -83,6 +84,11 @@ const dockerComposeServiceOptions = [
|
||||
},
|
||||
{ label: "restart", type: "keyword", info: "Restart policy" },
|
||||
{ label: "networks", type: "keyword", info: "Networks to join" },
|
||||
{
|
||||
label: "models",
|
||||
type: "keyword",
|
||||
info: "AI models to use, referencing top-level models",
|
||||
},
|
||||
].map((opt) => ({
|
||||
...opt,
|
||||
apply: (
|
||||
|
||||
@@ -35,6 +35,16 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
|
||||
"models": {
|
||||
"id": "#/properties/models",
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^[a-zA-Z0-9._-]+$": {
|
||||
"$ref": "#/definitions/model"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"networks": {
|
||||
"id": "#/properties/networks",
|
||||
"type": "object",
|
||||
@@ -335,6 +345,30 @@
|
||||
"mem_swappiness": { "type": ["integer", "string"] },
|
||||
"memswap_limit": { "type": ["number", "string"] },
|
||||
"network_mode": { "type": "string" },
|
||||
"models": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/definitions/list_of_strings" },
|
||||
{
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^[a-zA-Z0-9._-]+$": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"endpoint_var": { "type": "string" },
|
||||
"model_var": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"patternProperties": { "^x-": {} }
|
||||
},
|
||||
{ "type": "null" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"networks": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/definitions/list_of_strings" },
|
||||
@@ -890,6 +924,23 @@
|
||||
"patternProperties": { "^x-": {} }
|
||||
},
|
||||
|
||||
"model": {
|
||||
"id": "#/definitions/model",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"model": { "type": "string" },
|
||||
"context_size": { "type": "integer" },
|
||||
"runtime_flags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"required": ["model"],
|
||||
"additionalProperties": false,
|
||||
"patternProperties": { "^x-": {} }
|
||||
},
|
||||
|
||||
"command": {
|
||||
"oneOf": [
|
||||
{ "type": "null" },
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
CREATE TYPE "public"."sandboxNetworkMode" AS ENUM('isolated', 'internet');--> statement-breakpoint
|
||||
CREATE TYPE "public"."sandboxStatus" AS ENUM('creating', 'running', 'killed', 'error');--> statement-breakpoint
|
||||
CREATE TABLE "sandbox" (
|
||||
"sandboxId" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"environmentId" text NOT NULL,
|
||||
"serverId" text,
|
||||
"image" text NOT NULL,
|
||||
"template" text,
|
||||
"containerId" text,
|
||||
"status" "sandboxStatus" DEFAULT 'creating' NOT NULL,
|
||||
"cpu" real DEFAULT 1 NOT NULL,
|
||||
"memoryMb" integer DEFAULT 512 NOT NULL,
|
||||
"pidsLimit" integer DEFAULT 256 NOT NULL,
|
||||
"timeoutMs" integer DEFAULT 300000 NOT NULL,
|
||||
"expiresAt" timestamp with time zone,
|
||||
"lastActivityAt" timestamp with time zone,
|
||||
"networkMode" "sandboxNetworkMode" DEFAULT 'isolated' NOT NULL,
|
||||
"envVars" text,
|
||||
"workdir" text DEFAULT '/home/user' NOT NULL,
|
||||
"user" text,
|
||||
"runtime" text,
|
||||
"createdAt" text NOT NULL,
|
||||
"killedAt" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "sandbox" ADD CONSTRAINT "sandbox_environmentId_environment_environmentId_fk" FOREIGN KEY ("environmentId") REFERENCES "public"."environment"("environmentId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sandbox" ADD CONSTRAINT "sandbox_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1380,13 +1380,6 @@
|
||||
"when": 1788995453103,
|
||||
"tag": "0196_worthless_ravenous",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 197,
|
||||
"version": "7",
|
||||
"when": 1789022852219,
|
||||
"tag": "0197_demonic_the_initiative",
|
||||
"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",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.11",
|
||||
"next": "16.3.0",
|
||||
"next": "16.3.4",
|
||||
"next-themes": "^0.2.1",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
"node-os-utils": "2.0.1",
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { auth } from "@dokploy/server/index";
|
||||
import { toNodeHandler } from "better-auth/node";
|
||||
import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies";
|
||||
|
||||
// Disallow body parsing, we will parse it manually
|
||||
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;
|
||||
};
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: {
|
||||
sizeLimit: "25mb",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
|
||||
@@ -15,6 +15,14 @@ import {
|
||||
logWebhookError,
|
||||
} from "../[refreshToken]";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: {
|
||||
sizeLimit: "25mb",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
|
||||
@@ -26,6 +26,14 @@ import {
|
||||
const getGithubRepositoryOwner = (githubBody: any) =>
|
||||
githubBody?.repository?.owner?.name ?? githubBody?.repository?.owner?.login;
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: {
|
||||
sizeLimit: "25mb",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
sendInvoiceEmail,
|
||||
sendPaymentFailedEmail,
|
||||
} from "@/server/utils/stripe-notifications";
|
||||
import { sendTrialExpiringEmail } from "@/server/utils/trial-notifications";
|
||||
|
||||
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
|
||||
|
||||
@@ -72,6 +73,7 @@ export default async function handler(
|
||||
"invoice.payment_failed",
|
||||
"customer.deleted",
|
||||
"checkout.session.completed",
|
||||
"customer.subscription.trial_will_end",
|
||||
];
|
||||
|
||||
if (!webhooksAllowed.includes(event.type)) {
|
||||
@@ -110,6 +112,12 @@ export default async function handler(
|
||||
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
|
||||
break;
|
||||
}
|
||||
case "customer.subscription.trial_will_end": {
|
||||
const trialingSubscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
await sendTrialExpiringEmail(stripe, trialingSubscription, event.created);
|
||||
break;
|
||||
}
|
||||
case "customer.subscription.created": {
|
||||
const newSubscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
@@ -150,11 +158,9 @@ export default async function handler(
|
||||
newSubscription.customer as string,
|
||||
);
|
||||
|
||||
if (!admin) {
|
||||
return res.status(400).send("Webhook Error: Admin not found");
|
||||
}
|
||||
|
||||
if (admin.isEnterpriseCloud) {
|
||||
// The account may already be gone when the deletion flow cancelled
|
||||
// the subscription; nothing left to disable.
|
||||
if (!admin || admin.isEnterpriseCloud) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -176,7 +182,10 @@ export default async function handler(
|
||||
break;
|
||||
}
|
||||
|
||||
if (newSubscription.status === "active") {
|
||||
if (
|
||||
newSubscription.status === "active" ||
|
||||
newSubscription.status === "trialing"
|
||||
) {
|
||||
const serversQuantity = getSubscriptionServersQuantity(
|
||||
newSubscription?.items?.data ?? [],
|
||||
);
|
||||
@@ -209,9 +218,12 @@ export default async function handler(
|
||||
case "invoice.payment_succeeded": {
|
||||
const newInvoice = event.data.object as Stripe.Invoice;
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
newInvoice.subscription as string,
|
||||
);
|
||||
const subscriptionId = getInvoiceSubscriptionId(newInvoice);
|
||||
if (!subscriptionId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
if (subscription.status !== "active") {
|
||||
console.log(
|
||||
@@ -220,6 +232,14 @@ export default async function handler(
|
||||
break;
|
||||
}
|
||||
|
||||
const admin = await findUserByStripeCustomerId(
|
||||
subscription.customer as string,
|
||||
);
|
||||
|
||||
if (!admin) {
|
||||
return res.status(400).send("Webhook Error: Admin not found");
|
||||
}
|
||||
|
||||
const serversQuantity = getSubscriptionServersQuantity(
|
||||
subscription?.items?.data ?? [],
|
||||
);
|
||||
@@ -233,18 +253,10 @@ export default async function handler(
|
||||
),
|
||||
);
|
||||
|
||||
const admin = await findUserByStripeCustomerId(
|
||||
subscription.customer as string,
|
||||
);
|
||||
|
||||
if (!admin) {
|
||||
return res.status(400).send("Webhook Error: Admin not found");
|
||||
}
|
||||
if (admin.isEnterpriseCloud) {
|
||||
break;
|
||||
}
|
||||
const newServersQuantity = admin.serversQuantity;
|
||||
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
|
||||
await updateServersBasedOnQuantity(admin.id, serversQuantity);
|
||||
|
||||
if (admin.sendInvoiceNotifications) {
|
||||
await sendInvoiceEmail(newInvoice, admin);
|
||||
@@ -255,9 +267,12 @@ export default async function handler(
|
||||
case "invoice.payment_failed": {
|
||||
const newInvoice = event.data.object as Stripe.Invoice;
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
newInvoice.subscription as string,
|
||||
);
|
||||
const subscriptionId = getInvoiceSubscriptionId(newInvoice);
|
||||
if (!subscriptionId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
if (subscription.status !== "active") {
|
||||
const admin = await findUserByStripeCustomerId(
|
||||
newInvoice.customer as string,
|
||||
@@ -297,11 +312,7 @@ export default async function handler(
|
||||
const customer = event.data.object as Stripe.Customer;
|
||||
|
||||
const admin = await findUserByStripeCustomerId(customer.id);
|
||||
if (!admin) {
|
||||
return res.status(400).send("Webhook Error: Admin not found");
|
||||
}
|
||||
|
||||
if (admin.isEnterpriseCloud) {
|
||||
if (!admin || admin.isEnterpriseCloud) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -351,6 +362,12 @@ const findUserByStripeCustomerId = async (stripeCustomerId: string) => {
|
||||
return userResult;
|
||||
};
|
||||
|
||||
const getInvoiceSubscriptionId = (invoice: Stripe.Invoice): string | null => {
|
||||
const subscription = invoice.subscription;
|
||||
if (typeof subscription === "string") return subscription;
|
||||
return subscription?.id ?? null;
|
||||
};
|
||||
|
||||
const activateServer = async (serverId: string) => {
|
||||
await db
|
||||
.update(server)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import {
|
||||
Ban,
|
||||
Box,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronsUpDown,
|
||||
@@ -1063,14 +1062,6 @@ const EnvironmentPage = (
|
||||
<ProjectEnvironment projectId={projectId}>
|
||||
<Button variant="outline">Project Environment</Button>
|
||||
</ProjectEnvironment>
|
||||
<Link
|
||||
href={`/dashboard/project/${projectId}/environment/${environmentId}/sandboxes`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
<Box className="size-4" />
|
||||
Sandboxes
|
||||
</Button>
|
||||
</Link>
|
||||
{permissions?.service.create && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user