mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-19 07:01:35 +00:00
Compare commits
151 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ffbb516c31 | |||
| 853ca33659 | |||
| a90c2a662f | |||
| 45dcdfc5be | |||
| 29cdc815db | |||
| 9fa98de9c1 | |||
| a4a1d15656 | |||
| 8075a7ce95 | |||
| 326908869d | |||
| 18d07c7254 | |||
| 4cd4b83523 | |||
| f91b2e2ca6 | |||
| e70c880612 | |||
| 0c1dfe978c | |||
| 88118cdb3f | |||
| 38d157adc0 | |||
| 3b1fec5409 | |||
| 49b8214e5d | |||
| ec31739e0e | |||
| d5328703fa | |||
| f4cb26cbf4 | |||
| a6c1f3048f | |||
| 9cae609a0a | |||
| 2c234d6e20 | |||
| 9dcdb53aa1 | |||
| bda8124291 | |||
| 6dcd0e1859 | |||
| 85786a3196 | |||
| ea3f9f3b53 | |||
| e99a98e4fe | |||
| 5611662351 | |||
| 887a457f15 | |||
| df4e299545 | |||
| aaff71bbf6 | |||
| be128d5a4a | |||
| 467c80a73d | |||
| 2d5a6ef679 | |||
| 8291165259 | |||
| e701644fa5 | |||
| 94cd5035ef | |||
| ea1c6fc3fe | |||
| df0986b4e1 | |||
| caca3c19ae | |||
| 91db4e60c4 | |||
| 1e6ab0fab7 | |||
| 67f6890fc8 | |||
| 988f5b64d6 | |||
| e369541a16 | |||
| 7c1889205c | |||
| 0f28775a1e | |||
| 6ed5746983 | |||
| e8f94a94da | |||
| 150c34dc0a | |||
| 91eaab6a43 | |||
| f0f7843ece | |||
| 6e760da93b | |||
| 0c19973fbf | |||
| 600657ec62 | |||
| 383438e4fc | |||
| 57a5465d89 | |||
| ab87f2daad | |||
| 3b065db8a0 | |||
| 96778433db | |||
| f25810fa72 | |||
| 8409b8ce60 | |||
| 57d92b75de | |||
| 7e9fdbb5ff | |||
| 2311f346d4 | |||
| e9623119bd | |||
| d7884c7ce9 | |||
| 341f38bba6 | |||
| 28f71e727a | |||
| 29e5940e8d | |||
| 41251b4f1e | |||
| 15bf663aad | |||
| 13b043edd9 | |||
| 48dfb2b820 | |||
| 62e948a804 | |||
| b6c83e082e | |||
| b5c3d8660e | |||
| 5c0d3b84af | |||
| 7effae35cb | |||
| 7fb02abb3e | |||
| 631816f8d1 | |||
| 1a29694481 | |||
| 19589694ea | |||
| 1572008cdf | |||
| 5f10ed6884 | |||
| 399e20c1ed | |||
| 5faa363f7a | |||
| 5cbc7efc61 | |||
| 12ea7a8920 | |||
| 782bb4e249 | |||
| f4e1154d31 | |||
| bf547e2fd0 | |||
| 9568a4f9f8 | |||
| b839e6d6be | |||
| 7ae566f4de | |||
| ea9a466c01 | |||
| d1fda6368c | |||
| d389b3c8d0 | |||
| 0889d763ef | |||
| bbca400a6d | |||
| 03cdd00fa8 | |||
| ad00669f0f | |||
| f58ff1062e | |||
| c180f7502c | |||
| 4877c4ffa3 | |||
| dfa9c17d7d | |||
| e79ed7691f | |||
| e25e241cf5 | |||
| f617561d0b | |||
| f904a7bd55 | |||
| 261ebb2317 | |||
| c901260d81 | |||
| 8c9e473b4e | |||
| e2c9bd2833 | |||
| 8b38acdebd | |||
| 6dacab06cd | |||
| b2ed974422 | |||
| 62e43b164b | |||
| 4746cfc1ca | |||
| 18e617eb14 | |||
| 1d26fdd027 | |||
| 3893150f5c | |||
| 8806d4ef80 | |||
| 6d51c100ae | |||
| 13bb1ac5d1 | |||
| 5606a2bd1e | |||
| 58f3714ddc | |||
| 6282b6ef39 | |||
| f83097b80f | |||
| 59a74849a8 | |||
| d56ef890ae | |||
| b75785dc6a | |||
| 80a6baf58e | |||
| be4a5da405 | |||
| 2bc22c520d | |||
| e629248dea | |||
| ff3ebefc70 | |||
| c0f07f1427 | |||
| da3c0b1326 | |||
| 5344945f83 | |||
| f23b5b9532 | |||
| 79baa24763 | |||
| 6dc22bdeed | |||
| ac2e081134 | |||
| 26d716b34f | |||
| 2a7668662f | |||
| 49bf39dc18 | |||
| 0393c2635b |
@@ -16,7 +16,7 @@ No instance is running yet — start your own, isolated to this worktree:
|
||||
until it answers (usually ~10-15s).
|
||||
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
|
||||
|
||||
Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from
|
||||
Note: `mcp__dokploy__*` resolves its URL from
|
||||
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
|
||||
discovered mid-session. If those tools are unavailable or point at the wrong
|
||||
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
|
||||
|
||||
@@ -14,34 +14,34 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@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^^}
|
||||
|
||||
+2
-2
@@ -123,13 +123,13 @@ pnpm run docker:push
|
||||
In the case you lost your password, you can reset the owner's password using the following command
|
||||
|
||||
```bash
|
||||
pnpm run reset-password
|
||||
pnpm --filter=dokploy run reset-password
|
||||
```
|
||||
|
||||
To reset the password of a specific user instead, pass their email as an argument
|
||||
|
||||
```bash
|
||||
pnpm run reset-password -- user@example.com
|
||||
pnpm --filter=dokploy run reset-password user@example.com
|
||||
```
|
||||
|
||||
Both commands print the new randomly generated password to the console.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const base = {
|
||||
composeType: "docker-compose" as const,
|
||||
appName: "compose-app",
|
||||
sourceType: "github" as const,
|
||||
composePath: "./docker-compose.yml",
|
||||
command: "",
|
||||
};
|
||||
|
||||
describe("compose createCommand --pull always", () => {
|
||||
it("adds --pull always when pullImages is enabled", () => {
|
||||
const cmd = createCommand({ ...base, pullImages: true } as any);
|
||||
|
||||
expect(cmd).toContain("up -d --build --remove-orphans --pull always");
|
||||
});
|
||||
|
||||
it("omits --pull when pullImages is disabled", () => {
|
||||
const cmd = createCommand({ ...base, pullImages: false } as any);
|
||||
|
||||
expect(cmd).not.toContain("--pull");
|
||||
});
|
||||
|
||||
it("does not add --pull to stack deploy", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
composeType: "stack",
|
||||
pullImages: true,
|
||||
} as any);
|
||||
|
||||
expect(cmd).not.toContain("--pull");
|
||||
});
|
||||
|
||||
it("leaves a custom command untouched", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
command: "compose -p compose-app up -d",
|
||||
pullImages: true,
|
||||
} as any);
|
||||
|
||||
expect(cmd).toBe("compose -p compose-app up -d");
|
||||
});
|
||||
});
|
||||
@@ -54,7 +54,16 @@ const inputEncoding: Record<string, string> = {
|
||||
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
|
||||
};
|
||||
|
||||
describe("getCreateEnvFileCommand", () => {
|
||||
const hasDocker = () => {
|
||||
try {
|
||||
execFileSync("docker", ["info"], { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
describe.skipIf(!hasDocker())("getCreateEnvFileCommand", () => {
|
||||
it("writes special environment values that Docker Compose reads back literally", () => {
|
||||
mkdirSync(codePath, { recursive: 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -323,9 +323,11 @@ describe("cloudflareClient.upsertRecord", () => {
|
||||
expect(createInit.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("updates the existing record instead of creating a duplicate", async () => {
|
||||
it("updates the existing record when content matches", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }]))
|
||||
.mockResolvedValueOnce(
|
||||
cfSuccess([{ id: "existing-1", type: "A", content: "5.6.7.8" }]),
|
||||
)
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "existing-1" }));
|
||||
|
||||
const result = await cloudflareClient.upsertRecord(config, {
|
||||
@@ -344,6 +346,25 @@ describe("cloudflareClient.upsertRecord", () => {
|
||||
expect(updateInit.method).toBe("PUT");
|
||||
});
|
||||
|
||||
it("creates a new record when content differs from existing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
cfSuccess([{ id: "existing-1", type: "A", content: "1.1.1.1" }]),
|
||||
)
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "new-2" }));
|
||||
|
||||
const result = await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "5.6.7.8",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "new-2" });
|
||||
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(createInit.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("defaults ttl to 1 (automatic) when not provided", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as typeof fetch;
|
||||
|
||||
import { infomaniakClient } from "@dokploy/server/utils/dns/infomaniak";
|
||||
|
||||
const jsonResponse = (body: unknown, ok = true, status = 200) =>
|
||||
({
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
}) as Response;
|
||||
|
||||
const ikSuccess = (data: unknown) => jsonResponse({ result: "success", data });
|
||||
|
||||
const ikPage = (data: unknown, page: number, pages: number) =>
|
||||
jsonResponse({ result: "success", data, page, pages });
|
||||
|
||||
const ikError = (description: string, status = 400) =>
|
||||
jsonResponse(
|
||||
{ result: "error", error: { code: "not_authorized", description } },
|
||||
false,
|
||||
status,
|
||||
);
|
||||
|
||||
const config = {
|
||||
providerType: "infomaniak" as const,
|
||||
apiToken: "ik_test_token",
|
||||
};
|
||||
|
||||
const lastCall = () =>
|
||||
mockFetch.mock.calls.at(-1) as [string, RequestInit & { method?: string }];
|
||||
|
||||
const lastBody = () => JSON.parse(lastCall()[1].body as string);
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("infomaniakClient.listZones", () => {
|
||||
it("exposes each domain product as a zone keyed by its name", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
ikPage(
|
||||
[
|
||||
{ id: 1, customer_name: "example.com" },
|
||||
{ id: 2, customer_name: "example.ch" },
|
||||
],
|
||||
1,
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
const zones = await infomaniakClient.listZones(config);
|
||||
|
||||
expect(zones).toEqual([
|
||||
{ id: "example.com", name: "example.com" },
|
||||
{ id: "example.ch", name: "example.ch" },
|
||||
]);
|
||||
const [url, init] = lastCall();
|
||||
// The documented endpoint is the plural one; the singular is legacy and
|
||||
// returns no pagination metadata at all.
|
||||
expect(url).toContain("/1/products?service_name=domain");
|
||||
expect(url).toContain("page=1");
|
||||
expect(init.headers).toMatchObject({
|
||||
Authorization: "Bearer ik_test_token",
|
||||
});
|
||||
});
|
||||
|
||||
it("walks every page so accounts with many domains keep all their zones", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikPage([{ id: 1, customer_name: "a.com" }], 1, 3))
|
||||
.mockResolvedValueOnce(ikPage([{ id: 2, customer_name: "b.com" }], 2, 3))
|
||||
.mockResolvedValueOnce(ikPage([{ id: 3, customer_name: "c.com" }], 3, 3));
|
||||
|
||||
const zones = await infomaniakClient.listZones(config);
|
||||
|
||||
expect(zones.map((zone) => zone.name)).toEqual(["a.com", "b.com", "c.com"]);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
expect((mockFetch.mock.calls[2] as [string])[0]).toContain("page=3");
|
||||
});
|
||||
|
||||
it("stops after a single page when the response has no pagination", async () => {
|
||||
mockFetch.mockResolvedValue(ikSuccess([{ id: 1, customer_name: "a.com" }]));
|
||||
|
||||
const zones = await infomaniakClient.listZones(config);
|
||||
|
||||
expect(zones).toEqual([{ id: "a.com", name: "a.com" }]);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("propagates the API error description", async () => {
|
||||
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
|
||||
|
||||
await expect(infomaniakClient.listZones(config)).rejects.toThrow(
|
||||
"Authorization required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("infomaniakClient.listRecords", () => {
|
||||
it("rebuilds the fqdn from the relative source", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
ikSuccess([
|
||||
{ id: 10, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
|
||||
{ id: 11, type: "A", source: "", target: "5.6.7.8", ttl: 600 },
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
id: "10",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 300,
|
||||
},
|
||||
{
|
||||
id: "11",
|
||||
type: "A",
|
||||
name: "example.com",
|
||||
content: "5.6.7.8",
|
||||
ttl: 600,
|
||||
},
|
||||
]);
|
||||
expect(lastCall()[0]).toBe(
|
||||
"https://api.infomaniak.com/2/zones/example.com/records?with=records_description",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([".", "", "@"])("treats a %s source as the apex", async (source) => {
|
||||
mockFetch.mockResolvedValue(
|
||||
ikSuccess([{ id: 12, type: "A", source, target: "1.2.3.4", ttl: 300 }]),
|
||||
);
|
||||
|
||||
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||
|
||||
expect(records[0]?.name).toBe("example.com");
|
||||
});
|
||||
|
||||
it("unquotes TXT targets", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
ikSuccess([
|
||||
{
|
||||
id: 13,
|
||||
type: "TXT",
|
||||
source: "_acme-challenge",
|
||||
target: '"token-value"',
|
||||
ttl: 300,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||
|
||||
expect(records[0]?.content).toBe("token-value");
|
||||
});
|
||||
|
||||
it("leaves a CAA target untouched", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
ikSuccess([
|
||||
{
|
||||
id: 14,
|
||||
type: "CAA",
|
||||
source: "",
|
||||
target: '0 issue "letsencrypt.org"',
|
||||
ttl: 300,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||
|
||||
expect(records[0]?.content).toBe('0 issue "letsencrypt.org"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("infomaniakClient.upsertRecord", () => {
|
||||
it("creates the record when no matching source and type exists", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikSuccess([]))
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 42 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 600,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "42" });
|
||||
const [url, init] = lastCall();
|
||||
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(lastBody()).toEqual({
|
||||
type: "A",
|
||||
source: "app",
|
||||
target: "1.2.3.4",
|
||||
ttl: 600,
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the existing record when content matches", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{ id: 7, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 7 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "7" });
|
||||
const [url, init] = lastCall();
|
||||
expect(url).toBe(
|
||||
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
||||
);
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(lastBody().ttl).toBe(300);
|
||||
});
|
||||
|
||||
it("creates a new record when content differs from existing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{ id: 7, type: "A", source: "app", target: "1.1.1.1", ttl: 300 },
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 50 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "50" });
|
||||
const [url, init] = lastCall();
|
||||
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
|
||||
expect(init.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("writes a root dot as the source for an apex record and strips the trailing dot", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikSuccess([]))
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 43 }));
|
||||
|
||||
await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "example.com.",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(lastBody().source).toBe(".");
|
||||
});
|
||||
|
||||
it.each([".", "", "@"])(
|
||||
"matches an existing apex record stored with a %s source",
|
||||
async (source) => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{ id: 8, type: "A", source, target: "1.2.3.4", ttl: 3600 },
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "8" });
|
||||
expect(lastCall()[1].method).toBe("PUT");
|
||||
},
|
||||
);
|
||||
|
||||
it("matches the existing apex record instead of creating a duplicate", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{
|
||||
id: 8,
|
||||
type: "TXT",
|
||||
source: ".",
|
||||
target: '"v=spf1 -all"',
|
||||
ttl: 3600,
|
||||
},
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "TXT",
|
||||
name: "example.com",
|
||||
content: "v=spf1 -all",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "8" });
|
||||
const [url, init] = lastCall();
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(url).toBe(
|
||||
"https://api.infomaniak.com/2/zones/example.com/records/8",
|
||||
);
|
||||
expect(lastBody().target).toBe('"v=spf1 -all"');
|
||||
});
|
||||
|
||||
it("quotes a TXT target on write", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikSuccess([]))
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 44 }));
|
||||
|
||||
await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "TXT",
|
||||
name: "_acme-challenge.example.com",
|
||||
content: "token-value",
|
||||
});
|
||||
|
||||
expect(lastBody().target).toBe('"token-value"');
|
||||
});
|
||||
|
||||
it("does not double-quote a TXT target that is already quoted", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikSuccess([]))
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 45 }));
|
||||
|
||||
await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "TXT",
|
||||
name: "_acme-challenge.example.com",
|
||||
content: '"token-value"',
|
||||
});
|
||||
|
||||
expect(lastBody().target).toBe('"token-value"');
|
||||
});
|
||||
|
||||
it("queries the API with a source and type filter instead of the whole zone", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikSuccess([]))
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 50 }));
|
||||
|
||||
await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
const [url] = mockFetch.mock.calls[0] as [string];
|
||||
expect(url).toContain("filter%5Bsource%5D=app");
|
||||
expect(url).toContain("filter%5Btypes%5D%5B%5D=A");
|
||||
});
|
||||
|
||||
it("ignores a partial filter hit rather than overwriting a different record", async () => {
|
||||
// filter[source] matches substrings: asking for "auto" also returns
|
||||
// "autoconfig" and "autodiscover". Trusting it would overwrite one of them.
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{
|
||||
id: 61,
|
||||
type: "CNAME",
|
||||
source: "autoconfig",
|
||||
target: "a.example.net",
|
||||
ttl: 300,
|
||||
},
|
||||
{
|
||||
id: 62,
|
||||
type: "CNAME",
|
||||
source: "autodiscover",
|
||||
target: "b.example.net",
|
||||
ttl: 300,
|
||||
},
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 63 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "CNAME",
|
||||
name: "auto.example.com",
|
||||
content: "c.example.net",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "63" });
|
||||
expect(lastCall()[1].method).toBe("POST");
|
||||
});
|
||||
});
|
||||
|
||||
describe("infomaniakClient.updateRecord", () => {
|
||||
it("updates the record and keeps its id", async () => {
|
||||
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
|
||||
|
||||
const result = await infomaniakClient.updateRecord(
|
||||
config,
|
||||
"example.com",
|
||||
"7",
|
||||
{
|
||||
type: "CNAME",
|
||||
name: "www.example.com",
|
||||
content: "example.com",
|
||||
ttl: 900,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ id: "7" });
|
||||
const [url, init] = lastCall();
|
||||
expect(url).toBe(
|
||||
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
||||
);
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(lastBody()).toEqual({
|
||||
type: "CNAME",
|
||||
source: "www",
|
||||
target: "example.com",
|
||||
ttl: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the default ttl when none is provided", async () => {
|
||||
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
|
||||
|
||||
await infomaniakClient.updateRecord(config, "example.com", "7", {
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(lastBody().ttl).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe("infomaniakClient.deleteRecord", () => {
|
||||
it("deletes the record", async () => {
|
||||
mockFetch.mockResolvedValue(ikSuccess(null));
|
||||
|
||||
await infomaniakClient.deleteRecord(config, "example.com", "7");
|
||||
|
||||
const [url, init] = lastCall();
|
||||
expect(url).toBe(
|
||||
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
||||
);
|
||||
expect(init.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
it("propagates a delete failure", async () => {
|
||||
mockFetch.mockResolvedValue(ikError("Record not found", 404));
|
||||
|
||||
await expect(
|
||||
infomaniakClient.deleteRecord(config, "example.com", "7"),
|
||||
).rejects.toThrow("Record not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("infomaniakClient.testConnection", () => {
|
||||
it("resolves when the domain listing succeeds", async () => {
|
||||
mockFetch.mockResolvedValue(ikSuccess([]));
|
||||
|
||||
await expect(
|
||||
infomaniakClient.testConnection(config),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects on an invalid token", async () => {
|
||||
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
|
||||
|
||||
await expect(infomaniakClient.testConnection(config)).rejects.toThrow(
|
||||
"Infomaniak: request to /1/products?service_name=domain&per_page=1 failed: Authorization required",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,581 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as typeof fetch;
|
||||
|
||||
import { ovhClient } from "@dokploy/server/utils/dns/ovh";
|
||||
|
||||
const textResponse = (body: string, ok = true, status = 200) =>
|
||||
({
|
||||
ok,
|
||||
status,
|
||||
text: async () => body,
|
||||
}) as Response;
|
||||
|
||||
const ovhSuccess = (data: unknown) =>
|
||||
textResponse(data === undefined ? "" : JSON.stringify(data));
|
||||
|
||||
const ovhError = (message: string, status = 403) =>
|
||||
textResponse(JSON.stringify({ message }), false, status);
|
||||
|
||||
const SERVER_TIME = 1788268225;
|
||||
|
||||
const config = {
|
||||
providerType: "ovh" as const,
|
||||
endpoint: "ovh-eu" as const,
|
||||
applicationKey: "app-key",
|
||||
applicationSecret: "app-secret",
|
||||
consumerKey: "consumer-key",
|
||||
};
|
||||
|
||||
// Each test uses a distinct endpoint so the module-level clock-skew cache, which
|
||||
// is keyed by base url, never leaks a measurement between them.
|
||||
let endpointCursor = 0;
|
||||
const endpoints = [
|
||||
"ovh-eu",
|
||||
"ovh-ca",
|
||||
"ovh-us",
|
||||
"kimsufi-eu",
|
||||
"kimsufi-ca",
|
||||
"soyoustart-eu",
|
||||
"soyoustart-ca",
|
||||
] as const;
|
||||
const baseUrls: Record<(typeof endpoints)[number], string> = {
|
||||
"ovh-eu": "https://eu.api.ovh.com/1.0",
|
||||
"ovh-ca": "https://ca.api.ovh.com/1.0",
|
||||
"ovh-us": "https://api.us.ovhcloud.com/1.0",
|
||||
"kimsufi-eu": "https://eu.api.kimsufi.com/1.0",
|
||||
"kimsufi-ca": "https://ca.api.kimsufi.com/1.0",
|
||||
"soyoustart-eu": "https://eu.api.soyoustart.com/1.0",
|
||||
"soyoustart-ca": "https://ca.api.soyoustart.com/1.0",
|
||||
};
|
||||
|
||||
/** A config on a not-yet-used endpoint, so the first call always fetches /auth/time. */
|
||||
const freshConfig = () => {
|
||||
const endpoint = endpoints[
|
||||
endpointCursor % endpoints.length
|
||||
] as (typeof endpoints)[number];
|
||||
endpointCursor += 1;
|
||||
return { ...config, endpoint, baseUrl: baseUrls[endpoint] };
|
||||
};
|
||||
|
||||
/** Replies to /auth/time, then to each queued API response in order. */
|
||||
const mockApi = (...responses: Response[]) => {
|
||||
let call = 0;
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
if (url.endsWith("/auth/time")) {
|
||||
return Promise.resolve(textResponse(String(SERVER_TIME)));
|
||||
}
|
||||
const response = responses[call];
|
||||
call += 1;
|
||||
return Promise.resolve(response ?? ovhSuccess(null));
|
||||
});
|
||||
};
|
||||
|
||||
const apiCalls = () =>
|
||||
mockFetch.mock.calls.filter(
|
||||
([url]) => !(url as string).endsWith("/auth/time"),
|
||||
) as [string, RequestInit][];
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("ovhClient request signing", () => {
|
||||
it("signs the request with the API server clock, not the local one", async () => {
|
||||
const { baseUrl, ...cfg } = freshConfig();
|
||||
mockApi(ovhSuccess(["example.com"]));
|
||||
vi.spyOn(Date, "now").mockReturnValue((SERVER_TIME - 120) * 1000);
|
||||
|
||||
await ovhClient.listZones(cfg);
|
||||
|
||||
const [url, init] = apiCalls()[0] as [string, RequestInit];
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(url).toBe(`${baseUrl}/domain/zone`);
|
||||
expect(headers["X-Ovh-Timestamp"]).toBe(String(SERVER_TIME));
|
||||
expect(headers["X-Ovh-Application"]).toBe("app-key");
|
||||
expect(headers["X-Ovh-Consumer"]).toBe("consumer-key");
|
||||
|
||||
const expected = createHash("sha1")
|
||||
.update(
|
||||
["app-secret", "consumer-key", "GET", url, "", SERVER_TIME].join("+"),
|
||||
)
|
||||
.digest("hex");
|
||||
expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("signs a request body when one is sent", async () => {
|
||||
const { baseUrl, ...cfg } = freshConfig();
|
||||
mockApi(ovhSuccess([]), ovhSuccess({ id: 5 }), ovhSuccess(null));
|
||||
vi.spyOn(Date, "now").mockReturnValue(SERVER_TIME * 1000);
|
||||
|
||||
await ovhClient.upsertRecord(cfg, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
const [url, init] = apiCalls()[1] as [string, RequestInit];
|
||||
const headers = init.headers as Record<string, string>;
|
||||
const body = init.body as string;
|
||||
expect(body).not.toBe("");
|
||||
const expected = createHash("sha1")
|
||||
.update(
|
||||
["app-secret", "consumer-key", "POST", url, body, SERVER_TIME].join(
|
||||
"+",
|
||||
),
|
||||
)
|
||||
.digest("hex");
|
||||
expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.listZones", () => {
|
||||
it("maps each zone name to a zone", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhSuccess(["example.com", "example.fr"]));
|
||||
|
||||
const zones = await ovhClient.listZones(cfg);
|
||||
|
||||
expect(zones).toEqual([
|
||||
{ id: "example.com", name: "example.com" },
|
||||
{ id: "example.fr", name: "example.fr" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("names the missing root right when OVH refuses the zone listing", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhError("This call has not been granted", 403));
|
||||
|
||||
// A `GET /domain/zone/*` rule does not cover the bare `GET /domain/zone`,
|
||||
// so the raw OVH message would send users looking in the wrong place.
|
||||
await expect(ovhClient.listZones(cfg)).rejects.toThrow(
|
||||
/missing the `GET \/domain\/zone` right/,
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates the API error message", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhError("Invalid signature", 403));
|
||||
|
||||
await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.listRecords", () => {
|
||||
it("resolves each id into a full record", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess([1, 2]),
|
||||
ovhSuccess({
|
||||
id: 1,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.2.3.4",
|
||||
ttl: 600,
|
||||
}),
|
||||
ovhSuccess({
|
||||
id: 2,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: null,
|
||||
target: "5.6.7.8",
|
||||
ttl: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const records = await ovhClient.listRecords(cfg, "example.com");
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
id: "1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 600,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "A",
|
||||
name: "example.com",
|
||||
content: "5.6.7.8",
|
||||
ttl: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the records in the order of the returned ids", async () => {
|
||||
const cfg = freshConfig();
|
||||
const record = (id: number, subDomain: string) => ({
|
||||
id,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain,
|
||||
target: `10.0.0.${id}`,
|
||||
ttl: 60,
|
||||
});
|
||||
mockApi(
|
||||
ovhSuccess([1, 2, 3, 4, 5]),
|
||||
...[1, 2, 3, 4, 5].map((id) => ovhSuccess(record(id, `host${id}`))),
|
||||
);
|
||||
|
||||
const records = await ovhClient.listRecords(cfg, "example.com");
|
||||
|
||||
expect(records.map((r) => r.id)).toEqual(["1", "2", "3", "4", "5"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.upsertRecord", () => {
|
||||
it("creates the record then refreshes the zone", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
|
||||
|
||||
const result = await ovhClient.upsertRecord(cfg, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 600,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "9" });
|
||||
const calls = apiCalls();
|
||||
expect(calls[0]?.[0]).toContain(
|
||||
"/domain/zone/example.com/record?fieldType=A&subDomain=app",
|
||||
);
|
||||
expect(calls[1]?.[1].method).toBe("POST");
|
||||
expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.2.3.4",
|
||||
ttl: 600,
|
||||
});
|
||||
expect(calls[2]?.[0]).toContain("/domain/zone/example.com/refresh");
|
||||
expect(calls[2]?.[1].method).toBe("POST");
|
||||
});
|
||||
|
||||
it("updates the existing record when content matches", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess([4]),
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.2.3.4",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess(null),
|
||||
ovhSuccess(null),
|
||||
);
|
||||
|
||||
const result = await ovhClient.upsertRecord(cfg, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "4" });
|
||||
const calls = apiCalls();
|
||||
expect(calls[2]?.[0]).toContain("/domain/zone/example.com/record/4");
|
||||
expect(calls[2]?.[1].method).toBe("PUT");
|
||||
expect(calls[3]?.[0]).toContain("/refresh");
|
||||
});
|
||||
|
||||
it("creates a new record when content differs from existing", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess([4]),
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess({ id: 10 }),
|
||||
ovhSuccess(null),
|
||||
);
|
||||
|
||||
const result = await ovhClient.upsertRecord(cfg, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "5.6.7.8",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "10" });
|
||||
const calls = apiCalls();
|
||||
expect(calls[2]?.[1].method).toBe("POST");
|
||||
expect(calls[3]?.[0]).toContain("/refresh");
|
||||
});
|
||||
|
||||
it("omits the ttl so OVH applies the zone default", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
|
||||
|
||||
await ovhClient.upsertRecord(cfg, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(JSON.parse(apiCalls()[1]?.[1].body as string)).not.toHaveProperty(
|
||||
"ttl",
|
||||
);
|
||||
});
|
||||
|
||||
it("writes an empty subDomain for the apex and strips the trailing dot", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
|
||||
|
||||
await ovhClient.upsertRecord(cfg, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "example.com.",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(apiCalls()[0]?.[0]).toContain("subDomain=");
|
||||
expect(JSON.parse(apiCalls()[1]?.[1].body as string).subDomain).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.updateRecord", () => {
|
||||
it("updates in place when the type is unchanged", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess(null),
|
||||
ovhSuccess(null),
|
||||
);
|
||||
|
||||
const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 300,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "4" });
|
||||
const calls = apiCalls();
|
||||
expect(calls[1]?.[1].method).toBe("PUT");
|
||||
expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
|
||||
subDomain: "app",
|
||||
target: "1.2.3.4",
|
||||
ttl: 300,
|
||||
});
|
||||
expect(calls[2]?.[0]).toContain("/refresh");
|
||||
});
|
||||
|
||||
it("replaces the record when the type changes, since PUT carries no fieldType", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess(null),
|
||||
ovhSuccess({ id: 11 }),
|
||||
ovhSuccess(null),
|
||||
);
|
||||
|
||||
const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||
type: "CNAME",
|
||||
name: "app.example.com",
|
||||
content: "example.com",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "11" });
|
||||
const calls = apiCalls();
|
||||
expect(calls[1]?.[1].method).toBe("DELETE");
|
||||
expect(calls[2]?.[1].method).toBe("POST");
|
||||
expect(JSON.parse(calls[2]?.[1].body as string).fieldType).toBe("CNAME");
|
||||
expect(calls[3]?.[0]).toContain("/refresh");
|
||||
});
|
||||
|
||||
it("restores the original record when the replacement fails", async () => {
|
||||
const cfg = freshConfig();
|
||||
const original = {
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
};
|
||||
mockApi(
|
||||
ovhSuccess(original),
|
||||
ovhSuccess(null),
|
||||
ovhError("Invalid target", 400),
|
||||
ovhSuccess({ id: 12 }),
|
||||
ovhSuccess(null),
|
||||
);
|
||||
|
||||
await expect(
|
||||
ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||
type: "CNAME",
|
||||
name: "app.example.com",
|
||||
content: "not a valid target",
|
||||
}),
|
||||
).rejects.toThrow("Invalid target");
|
||||
|
||||
const calls = apiCalls();
|
||||
expect(calls[1]?.[1].method).toBe("DELETE");
|
||||
expect(calls[2]?.[1].method).toBe("POST");
|
||||
// The original record is put back with its own type, target and ttl.
|
||||
expect(JSON.parse(calls[3]?.[1].body as string)).toEqual({
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
});
|
||||
expect(calls[4]?.[0]).toContain("/refresh");
|
||||
});
|
||||
|
||||
it("reports the lost record when the restore also fails", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess(null),
|
||||
ovhError("Invalid target", 400),
|
||||
ovhError("Service unavailable", 503),
|
||||
);
|
||||
|
||||
await expect(
|
||||
ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||
type: "CNAME",
|
||||
name: "app.example.com",
|
||||
content: "not a valid target",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/Recreate it manually: A app\.example\.com -> 1\.1\.1\.1/,
|
||||
);
|
||||
});
|
||||
|
||||
it("says the change was applied when only the zone refresh fails", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess(null),
|
||||
ovhSuccess({ id: 11 }),
|
||||
ovhError("Service unavailable", 503),
|
||||
);
|
||||
|
||||
// The replacement succeeded, so the record exists at the provider — only
|
||||
// publishing failed. Rolling back would destroy correct state.
|
||||
await expect(
|
||||
ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||
type: "CNAME",
|
||||
name: "app.example.com",
|
||||
content: "example.com",
|
||||
}),
|
||||
).rejects.toThrow(/was applied, but refreshing zone "example\.com" failed/);
|
||||
});
|
||||
|
||||
it("does not tell the user to recreate a record that was restored but not published", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess(null), // DELETE de l'ancien
|
||||
ovhError("Invalid target", 400), // POST de remplacement -> échec
|
||||
ovhSuccess({ id: 12 }), // POST de restauration -> succès
|
||||
ovhError("Service unavailable", 503), // refresh -> échec
|
||||
);
|
||||
|
||||
const attempt = ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||
type: "CNAME",
|
||||
name: "app.example.com",
|
||||
content: "not a valid target",
|
||||
});
|
||||
|
||||
// L'enregistrement existe de nouveau chez OVH : le recréer le dupliquerait.
|
||||
await expect(attempt).rejects.toThrow(/was restored, but refreshing zone/);
|
||||
await expect(attempt).rejects.not.toThrow(/Recreate it manually/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.deleteRecord", () => {
|
||||
it("deletes the record then refreshes the zone", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhSuccess(null), ovhSuccess(null));
|
||||
|
||||
await ovhClient.deleteRecord(cfg, "example.com", "4");
|
||||
|
||||
const calls = apiCalls();
|
||||
expect(calls[0]?.[0]).toContain("/domain/zone/example.com/record/4");
|
||||
expect(calls[0]?.[1].method).toBe("DELETE");
|
||||
expect(calls[1]?.[0]).toContain("/domain/zone/example.com/refresh");
|
||||
});
|
||||
|
||||
it("does not refresh the zone when the delete fails", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhError("This object does not exist", 404));
|
||||
|
||||
await expect(
|
||||
ovhClient.deleteRecord(cfg, "example.com", "4"),
|
||||
).rejects.toThrow("This object does not exist");
|
||||
expect(apiCalls()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.testConnection", () => {
|
||||
it("resolves when the zone listing succeeds", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhSuccess([]));
|
||||
|
||||
await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects on invalid credentials", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhError("Invalid signature", 403));
|
||||
|
||||
await expect(ovhClient.testConnection(cfg)).rejects.toThrow(
|
||||
"Invalid signature",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -121,9 +121,11 @@ describe("porkbunClient.upsertRecord", () => {
|
||||
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/");
|
||||
});
|
||||
|
||||
it("edits the existing record instead of creating a duplicate", async () => {
|
||||
it("edits the existing record when content matches", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(pbSuccess({ records: [{ id: "existing-1" }] }))
|
||||
.mockResolvedValueOnce(
|
||||
pbSuccess({ records: [{ id: "existing-1", content: "5.6.7.8" }] }),
|
||||
)
|
||||
.mockResolvedValueOnce(pbSuccess({}));
|
||||
|
||||
const result = await porkbunClient.upsertRecord(config, {
|
||||
@@ -138,6 +140,30 @@ describe("porkbunClient.upsertRecord", () => {
|
||||
expect(editUrl).toContain("/dns/edit/example.com/existing-1");
|
||||
});
|
||||
|
||||
it("creates a new record when content differs from existing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
pbSuccess({ records: [{ id: "existing-1", content: "1.1.1.1" }] }),
|
||||
)
|
||||
.mockResolvedValueOnce(pbSuccess({ id: "new-2" }));
|
||||
|
||||
const result = await porkbunClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "5.6.7.8",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "new-2" });
|
||||
const [createUrl, createInit] = mockFetch.mock.calls[1] as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(createUrl).toContain("/dns/create/example.com");
|
||||
const body = JSON.parse(createInit.body as string);
|
||||
expect(body).toMatchObject({ name: "app", type: "A", content: "5.6.7.8" });
|
||||
});
|
||||
|
||||
it("defaults ttl to 600 when not provided", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(pbSuccess({ records: [] }))
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import os from "node:os";
|
||||
import {
|
||||
getServerIpCandidates,
|
||||
validateDomain,
|
||||
} from "@dokploy/server/services/domain";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execAsyncRemote: vi.fn(),
|
||||
findServerById: vi.fn(),
|
||||
getPublicIpWithFallback: vi.fn(),
|
||||
getWebServerSettings: vi.fn(),
|
||||
resolve4: vi.fn(),
|
||||
resolve6: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:dns", () => ({
|
||||
default: {
|
||||
resolve4: mocks.resolve4,
|
||||
resolve6: mocks.resolve6,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsyncRemote: mocks.execAsyncRemote,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServerById: mocks.findServerById,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/web-server-settings", () => ({
|
||||
getWebServerSettings: mocks.getWebServerSettings,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/wss/utils", () => ({
|
||||
getPublicIpWithFallback: mocks.getPublicIpWithFallback,
|
||||
}));
|
||||
|
||||
describe("getServerIpCandidates", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("includes every address reported by a multi-homed remote server", async () => {
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
ipAddress: "10.0.0.10",
|
||||
});
|
||||
mocks.execAsyncRemote.mockResolvedValue({
|
||||
stdout: ["10.0.0.10", "192.0.2.10", "2001:db8::10"].join("\n"),
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
await expect(getServerIpCandidates("server-id")).resolves.toEqual([
|
||||
"10.0.0.10",
|
||||
"192.0.2.10",
|
||||
"2001:db8::10",
|
||||
]);
|
||||
expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
|
||||
"server-id",
|
||||
expect.stringContaining("ip -o addr show scope global"),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes every address assigned to the local Dokploy host", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
serverIp: "10.0.0.10",
|
||||
});
|
||||
mocks.getPublicIpWithFallback.mockResolvedValue("2001:db8::10");
|
||||
vi.spyOn(os, "networkInterfaces").mockReturnValue({
|
||||
eth0: [
|
||||
{
|
||||
address: "192.0.2.10",
|
||||
netmask: "255.255.255.0",
|
||||
family: "IPv4",
|
||||
mac: "00:00:00:00:00:00",
|
||||
internal: false,
|
||||
cidr: "192.0.2.10/24",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(getServerIpCandidates()).resolves.toEqual([
|
||||
"10.0.0.10",
|
||||
"192.0.2.10",
|
||||
"2001:db8::10",
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains remote interface addresses when public IP detection times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
ipAddress: "10.0.0.10",
|
||||
});
|
||||
mocks.execAsyncRemote.mockImplementation(
|
||||
(_serverId: string, command: string) => {
|
||||
if (command.includes("curl")) {
|
||||
return new Promise(() => undefined);
|
||||
}
|
||||
return Promise.resolve({
|
||||
stdout: "192.0.2.10\n",
|
||||
stderr: "",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const candidatesPromise = getServerIpCandidates("server-id");
|
||||
await vi.advanceTimersByTimeAsync(7000);
|
||||
|
||||
await expect(candidatesPromise).resolves.toEqual([
|
||||
"10.0.0.10",
|
||||
"192.0.2.10",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateDomain", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("validates an IPv6-only domain against an IPv6 server address", async () => {
|
||||
const noIpv4 = Object.assign(new Error("queryA ENODATA example.com"), {
|
||||
code: "ENODATA",
|
||||
});
|
||||
mocks.resolve4.mockImplementation(
|
||||
(_domain: string, callback: (error: Error | null) => void) =>
|
||||
callback(noIpv4),
|
||||
);
|
||||
mocks.resolve6.mockImplementation(
|
||||
(
|
||||
_domain: string,
|
||||
callback: (error: Error | null, addresses?: string[]) => void,
|
||||
) => callback(null, ["2001:db8::10"]),
|
||||
);
|
||||
|
||||
await expect(
|
||||
validateDomain("example.com", ["2001:db8::10"]),
|
||||
).resolves.toMatchObject({
|
||||
isValid: true,
|
||||
resolvedIp: "2001:db8::10",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type HasInput = { input: Record<string, unknown> };
|
||||
|
||||
const findMany = vi.hoisted(() => vi.fn());
|
||||
|
||||
const {
|
||||
send,
|
||||
paginate,
|
||||
SSMClient,
|
||||
GetParametersCommand,
|
||||
DescribeParametersCommand,
|
||||
} = vi.hoisted(() => {
|
||||
class FakeCommand {
|
||||
input: Record<string, unknown>;
|
||||
constructor(input: Record<string, unknown>) {
|
||||
this.input = input;
|
||||
}
|
||||
}
|
||||
const send = vi.fn();
|
||||
const paginate = vi.fn();
|
||||
class SSMClient {
|
||||
send(command: unknown) {
|
||||
return send(command);
|
||||
}
|
||||
}
|
||||
return {
|
||||
send,
|
||||
paginate,
|
||||
SSMClient,
|
||||
GetParametersCommand: class extends FakeCommand {},
|
||||
DescribeParametersCommand: class extends FakeCommand {},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@aws-sdk/client-ssm", () => ({
|
||||
SSMClient,
|
||||
GetParametersCommand,
|
||||
DescribeParametersCommand,
|
||||
paginateDescribeParameters: paginate,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
vaultProvider: {
|
||||
findMany: (...args: unknown[]) => findMany(...args),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { resolveVaultReferences } from "@dokploy/server/utils/vault";
|
||||
import { awsParameterStoreClient } from "@dokploy/server/utils/vault/aws-parameter-store";
|
||||
|
||||
const config = {
|
||||
providerType: "aws-parameter-store" as const,
|
||||
region: "eu-central-1",
|
||||
accessKeyId: "AKIA_TEST",
|
||||
secretAccessKey: "secret",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
send.mockReset();
|
||||
paginate.mockReset();
|
||||
findMany.mockReset();
|
||||
});
|
||||
|
||||
describe("awsParameterStoreClient", () => {
|
||||
it("decrypts parameters in batches of ten and preserves selectors", async () => {
|
||||
const refs = [
|
||||
"/prod/database:CURRENT",
|
||||
...Array.from({ length: 10 }, (_, index) => `/prod/secret-${index}`),
|
||||
];
|
||||
send.mockImplementation(async (command: HasInput) => ({
|
||||
Parameters: (command.input.Names as string[]).map((ref) => {
|
||||
if (ref === "/prod/database:CURRENT") {
|
||||
return {
|
||||
Name: "/prod/database",
|
||||
Selector: ":CURRENT",
|
||||
Value: "selected-value",
|
||||
};
|
||||
}
|
||||
return { Name: ref, Value: `value-for-${ref}` };
|
||||
}),
|
||||
}));
|
||||
|
||||
const result = await awsParameterStoreClient.getSecrets(config, refs);
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect((send.mock.calls[0]?.[0] as HasInput).input).toMatchObject({
|
||||
WithDecryption: true,
|
||||
});
|
||||
expect(
|
||||
((send.mock.calls[0]?.[0] as HasInput).input.Names as string[]).length,
|
||||
).toBe(10);
|
||||
expect(
|
||||
((send.mock.calls[1]?.[0] as HasInput).input.Names as string[]).length,
|
||||
).toBe(1);
|
||||
expect(result["/prod/database:CURRENT"]).toBe("selected-value");
|
||||
expect(result["/prod/secret-9"]).toBe("value-for-/prod/secret-9");
|
||||
});
|
||||
|
||||
it("reports a missing parameter without exposing other values", async () => {
|
||||
send.mockResolvedValue({ Parameters: [], InvalidParameters: ["/missing"] });
|
||||
|
||||
await expect(
|
||||
awsParameterStoreClient.getSecrets(config, ["/missing"]),
|
||||
).rejects.toThrow('AWS Parameter Store: parameter "/missing" not found');
|
||||
});
|
||||
|
||||
it("tests the connection within the configured hierarchy", async () => {
|
||||
send.mockResolvedValue({ Parameters: [] });
|
||||
|
||||
await awsParameterStoreClient.testConnection({
|
||||
...config,
|
||||
parameterPath: "/production/my-app/",
|
||||
});
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect((send.mock.calls[0]?.[0] as HasInput).input).toEqual({
|
||||
ParameterFilters: [
|
||||
{
|
||||
Key: "Path",
|
||||
Option: "Recursive",
|
||||
Values: ["/production/my-app"],
|
||||
},
|
||||
],
|
||||
MaxResults: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("explains the discovery permission when connection testing is denied", async () => {
|
||||
const error = new Error("not authorized");
|
||||
error.name = "AccessDeniedException";
|
||||
send.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
awsParameterStoreClient.testConnection(config),
|
||||
).rejects.toThrow("ssm:DescribeParameters");
|
||||
});
|
||||
|
||||
it("lists parameter names across pages within the configured hierarchy", async () => {
|
||||
paginate.mockReturnValue(
|
||||
(async function* () {
|
||||
yield { Parameters: [{ Name: "/prod/db" }] };
|
||||
yield { Parameters: [{ Name: "/prod/api" }] };
|
||||
})(),
|
||||
);
|
||||
|
||||
const names = await awsParameterStoreClient.listSecretNames?.({
|
||||
...config,
|
||||
parameterPath: " /production/my-app/ ",
|
||||
});
|
||||
|
||||
expect(names).toEqual(["/prod/db", "/prod/api"]);
|
||||
expect(paginate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pageSize: 50 }),
|
||||
{
|
||||
ParameterFilters: [
|
||||
{
|
||||
Key: "Path",
|
||||
Option: "Recursive",
|
||||
Values: ["/production/my-app"],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a vault reference through the registered provider", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "ssm-prod",
|
||||
providerType: "aws-parameter-store",
|
||||
config,
|
||||
assignments: [{ projectId: "project-1", environmentIds: [] }],
|
||||
},
|
||||
]);
|
||||
send.mockResolvedValue({
|
||||
Parameters: [{ Name: "/prod/database-password", Value: "resolved" }],
|
||||
});
|
||||
|
||||
const result = await resolveVaultReferences(
|
||||
"DB_PASSWORD=${{vault.ssm-prod./prod/database-password}}",
|
||||
{
|
||||
organizationId: "organization-1",
|
||||
projectId: "project-1",
|
||||
environmentId: "environment-1",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe("DB_PASSWORD=resolved");
|
||||
});
|
||||
});
|
||||
+218
@@ -20,6 +20,7 @@ import {
|
||||
import { azureClient } from "@dokploy/server/utils/vault/azure";
|
||||
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
|
||||
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
|
||||
import { infisicalClient } from "@dokploy/server/utils/vault/infisical";
|
||||
import { phaseClient } from "@dokploy/server/utils/vault/phase";
|
||||
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
|
||||
|
||||
@@ -431,6 +432,223 @@ describe("azure client", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("infisical client", () => {
|
||||
const config = {
|
||||
providerType: "infisical" as const,
|
||||
siteUrl: "https://app.infisical.com",
|
||||
clientId: "client-1",
|
||||
clientSecret: "client-secret",
|
||||
projectId: "workspace-1",
|
||||
environmentSlug: "prod",
|
||||
secretPath: "/frontend",
|
||||
};
|
||||
|
||||
const loginResponse = () => jsonResponse({ accessToken: "token-1" });
|
||||
const list = (secrets: Record<string, string>) =>
|
||||
jsonResponse({
|
||||
secrets: Object.entries(secrets).map(([secretKey, secretValue]) => ({
|
||||
secretKey,
|
||||
secretValue,
|
||||
})),
|
||||
});
|
||||
const listPathOf = (callIndex: number) => {
|
||||
const [url] = mockFetch.mock.calls[callIndex] as [string];
|
||||
return new URL(url).searchParams.get("secretPath");
|
||||
};
|
||||
|
||||
it("asks the list endpoint to expand secret references", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({ DB_URL: "postgres://real" }));
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||
|
||||
expect(result).toEqual({ DB_URL: "postgres://real" });
|
||||
const [listUrl] = mockFetch.mock.calls[1] as [string];
|
||||
const params = new URL(listUrl).searchParams;
|
||||
expect(params.get("expandSecretReferences")).toBe("true");
|
||||
expect(params.get("workspaceId")).toBe("workspace-1");
|
||||
expect(params.get("environment")).toBe("prod");
|
||||
expect(params.get("secretPath")).toBe("/frontend");
|
||||
});
|
||||
|
||||
it("asks for imported secrets and merges them", async () => {
|
||||
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
secrets: [],
|
||||
imports: [
|
||||
{
|
||||
secretPath: "/shared",
|
||||
secrets: [
|
||||
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||
|
||||
expect(result).toEqual({ DB_URL: "postgres://imported" });
|
||||
const [listUrl] = mockFetch.mock.calls[1] as [string];
|
||||
// snake_case on purpose: `includeImports` is silently ignored and the
|
||||
// response comes back with an empty `imports` array.
|
||||
expect(new URL(listUrl).searchParams.get("include_imports")).toBe("true");
|
||||
});
|
||||
|
||||
it("lets the later import win when two define the same key", async () => {
|
||||
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
secrets: [],
|
||||
imports: [
|
||||
{
|
||||
secretPath: "/base",
|
||||
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://base" }],
|
||||
},
|
||||
{
|
||||
secretPath: "/override",
|
||||
secrets: [
|
||||
{ secretKey: "DB_URL", secretValue: "postgres://override" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||
|
||||
// Infisical documents this order: "If two imports carry a secret with the
|
||||
// same name, the value from the bottom-most import wins." The response
|
||||
// lists imports in that order, so assigning them in sequence matches it.
|
||||
expect(result).toEqual({ DB_URL: "postgres://override" });
|
||||
});
|
||||
|
||||
it("lets a folder's own secret win over an imported one of the same name", async () => {
|
||||
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://local" }],
|
||||
imports: [
|
||||
{
|
||||
secretPath: "/shared",
|
||||
secrets: [
|
||||
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||
|
||||
expect(result).toEqual({ DB_URL: "postgres://local" });
|
||||
});
|
||||
|
||||
it("throws a clear error for a missing secret", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(jsonResponse({ secrets: [] }));
|
||||
|
||||
await expect(
|
||||
infisicalClient.getSecrets(config, ["ABSENT"]),
|
||||
).rejects.toThrow('secret "ABSENT" not found in environment "prod"');
|
||||
});
|
||||
|
||||
it("propagates authentication failures with the status code", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({}, false, 401));
|
||||
|
||||
await expect(
|
||||
infisicalClient.getSecrets(config, ["DB_URL"]),
|
||||
).rejects.toThrow("authentication failed (status 401)");
|
||||
});
|
||||
|
||||
it("resolves a relative <path>:<KEY> ref against the provider path", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, [
|
||||
"shared/sentry:SENTRY_DSN",
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
"shared/sentry:SENTRY_DSN": "https://key@sentry.io/1",
|
||||
});
|
||||
expect(listPathOf(1)).toBe("/frontend/shared/sentry");
|
||||
});
|
||||
|
||||
it("treats a leading slash as an absolute path", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
|
||||
|
||||
await infisicalClient.getSecrets(config, ["/external/sentry:SENTRY_DSN"]);
|
||||
|
||||
expect(listPathOf(1)).toBe("/external/sentry");
|
||||
});
|
||||
|
||||
it("keeps the root path clean when the provider sits at /", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({ KEY: "value" }));
|
||||
|
||||
await infisicalClient.getSecrets({ ...config, secretPath: "/" }, [
|
||||
"external/sentry:KEY",
|
||||
]);
|
||||
|
||||
expect(listPathOf(1)).toBe("/external/sentry");
|
||||
});
|
||||
|
||||
it("logs in once and fetches each path once", async () => {
|
||||
const byPath: Record<string, Record<string, string>> = {
|
||||
"/frontend": { A: "a", B: "b" },
|
||||
"/frontend/other": { C: "c" },
|
||||
};
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("/auth/universal-auth/login")) return loginResponse();
|
||||
const path = new URL(url).searchParams.get("secretPath") as string;
|
||||
return list(byPath[path] ?? {});
|
||||
});
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, [
|
||||
"A",
|
||||
"B",
|
||||
"other:C",
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ A: "a", B: "b", "other:C": "c" });
|
||||
|
||||
const urls = mockFetch.mock.calls.map(([url]) => url as string);
|
||||
expect(urls.filter((u) => u.includes("/login"))).toHaveLength(1);
|
||||
expect(
|
||||
urls
|
||||
.filter((u) => u.includes("/secrets/raw?"))
|
||||
.map((u) => new URL(u).searchParams.get("secretPath"))
|
||||
.sort(),
|
||||
).toEqual(["/frontend", "/frontend/other"]);
|
||||
});
|
||||
|
||||
it("names the path when a secret is missing from an explicit one", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({}));
|
||||
|
||||
await expect(
|
||||
infisicalClient.getSecrets(config, ["external/sentry:ABSENT"]),
|
||||
).rejects.toThrow(
|
||||
'secret "ABSENT" not found at "/frontend/external/sentry"',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a ref with an empty path or key", async () => {
|
||||
await expect(infisicalClient.getSecrets(config, [":KEY"])).rejects.toThrow(
|
||||
"expected format <path>:<KEY>",
|
||||
);
|
||||
await expect(
|
||||
infisicalClient.getSecrets(config, ["external/sentry:"]),
|
||||
).rejects.toThrow("expected format <path>:<KEY>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("doppler client", () => {
|
||||
it("propagates auth errors with the status code", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
|
||||
|
||||
@@ -154,7 +154,16 @@ const hasRealMonitoring = () => {
|
||||
);
|
||||
};
|
||||
|
||||
describe.skipIf(hasRealMonitoring())(
|
||||
const hasDocker = () => {
|
||||
try {
|
||||
execSync("docker info", { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
describe.skipIf(!hasDocker() || hasRealMonitoring() || !process.env.CI)(
|
||||
"setupMonitoring - legacy container cleanup (real docker)",
|
||||
() => {
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -60,7 +60,7 @@ const baseSettings: WebServerSettings = {
|
||||
docsUrl: null,
|
||||
errorPageTitle: null,
|
||||
errorPageDescription: null,
|
||||
metaTitle: null,
|
||||
ogImageUrl: null,
|
||||
footerText: null,
|
||||
},
|
||||
cleanupCacheApplications: false,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServerById: vi.fn(),
|
||||
}));
|
||||
|
||||
import { pipeBetweenServers } from "@dokploy/server/utils/process/remoteStream";
|
||||
|
||||
describe("pipeBetweenServers", () => {
|
||||
it("delivers a short source stream that ends before the target is ready", async () => {
|
||||
const bytes = await pipeBetweenServers({
|
||||
source: { serverId: null, command: "printf 'hello world'" },
|
||||
target: { serverId: null, command: "sleep 0.3; cat > /dev/null" },
|
||||
});
|
||||
expect(bytes).toBe(11);
|
||||
});
|
||||
|
||||
it("pipes data larger than the pipe buffer unchanged", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-"));
|
||||
const source = path.join(dir, "src.bin");
|
||||
const target = path.join(dir, "dst.bin");
|
||||
const size = 1024 * 1024;
|
||||
const progress: number[] = [];
|
||||
|
||||
const bytes = await pipeBetweenServers({
|
||||
source: {
|
||||
serverId: null,
|
||||
command: `head -c ${size} /dev/urandom | tee '${source}'`,
|
||||
},
|
||||
target: { serverId: null, command: `cat > '${target}'` },
|
||||
onProgress: (transferred) => progress.push(transferred),
|
||||
});
|
||||
|
||||
expect(bytes).toBe(size);
|
||||
expect(progress.at(-1)).toBe(size);
|
||||
expect(await fs.readFile(target)).toEqual(await fs.readFile(source));
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}, 30_000);
|
||||
|
||||
it("reports a target failure with its stderr", async () => {
|
||||
await expect(
|
||||
pipeBetweenServers({
|
||||
source: { serverId: null, command: "printf x" },
|
||||
target: { serverId: null, command: "echo boom >&2; exit 3" },
|
||||
}),
|
||||
).rejects.toThrow("target exited with code 3: boom");
|
||||
});
|
||||
|
||||
it("reports a source failure", async () => {
|
||||
await expect(
|
||||
pipeBetweenServers({
|
||||
source: { serverId: null, command: "exit 2" },
|
||||
target: { serverId: null, command: "cat > /dev/null" },
|
||||
}),
|
||||
).rejects.toThrow("source exited with code 2");
|
||||
});
|
||||
});
|
||||
@@ -8,4 +8,3 @@ export { RestartPolicyForm } from "./restart-policy-form";
|
||||
export { RollbackConfigForm } from "./rollback-config-form";
|
||||
export { StopGracePeriodForm } from "./stop-grace-period-form";
|
||||
export { UpdateConfigForm } from "./update-config-form";
|
||||
export { filterEmptyValues, hasValues } from "./utils";
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* Filters out undefined, null, and empty string values from form data
|
||||
* Only returns fields that have actual values
|
||||
*/
|
||||
export const filterEmptyValues = (
|
||||
formData: Record<string, any>,
|
||||
): Record<string, any> => {
|
||||
return Object.entries(formData).reduce(
|
||||
(acc, [key, value]) => {
|
||||
// Keep arrays even if empty (they might be intentionally cleared)
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) {
|
||||
acc[key] = value;
|
||||
}
|
||||
}
|
||||
// For other values, filter out undefined, null, and empty strings
|
||||
else if (value !== undefined && value !== null && value !== "") {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if filtered data has any values to save
|
||||
*/
|
||||
export const hasValues = (data: Record<string, any>): boolean => {
|
||||
return Object.keys(data).length > 0;
|
||||
};
|
||||
@@ -31,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 {
|
||||
|
||||
@@ -156,7 +156,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
domainId,
|
||||
},
|
||||
{
|
||||
enabled: !!domainId,
|
||||
enabled: isOpen && !!domainId,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -167,7 +167,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
applicationId: id,
|
||||
},
|
||||
{
|
||||
enabled: !!id,
|
||||
enabled: isOpen && !!id,
|
||||
},
|
||||
)
|
||||
: api.compose.one.useQuery(
|
||||
@@ -175,7 +175,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
composeId: id,
|
||||
},
|
||||
{
|
||||
enabled: !!id,
|
||||
enabled: isOpen && !!id,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -187,9 +187,14 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
api.domain.generateDomain.useMutation();
|
||||
|
||||
const { data: canGenerateTraefikMeDomains } =
|
||||
api.domain.canGenerateTraefikMeDomains.useQuery({
|
||||
serverId: application?.serverId || "",
|
||||
});
|
||||
api.domain.canGenerateTraefikMeDomains.useQuery(
|
||||
{
|
||||
serverId: application?.serverId || "",
|
||||
},
|
||||
{
|
||||
enabled: isOpen,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: services,
|
||||
@@ -204,7 +209,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
{
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: type === "compose" && !!id,
|
||||
enabled: isOpen && type === "compose" && !!id,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -14,6 +13,7 @@ import { Dropzone } from "@/components/ui/dropzone";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
|
||||
import { api } from "@/utils/api";
|
||||
import { sanitizeSvg } from "@/utils/sanitize-svg";
|
||||
|
||||
interface ShowIconSettingsProps {
|
||||
serviceId: string;
|
||||
@@ -89,15 +89,6 @@ export const ShowIconSettings = ({
|
||||
}
|
||||
};
|
||||
|
||||
const sanitizeSvg = (svgContent: string): string | null => {
|
||||
const clean = DOMPurify.sanitize(svgContent, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
ADD_TAGS: ["use"],
|
||||
});
|
||||
if (!clean) return null;
|
||||
return `data:image/svg+xml;base64,${btoa(clean)}`;
|
||||
};
|
||||
|
||||
const handleFileUpload = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
const file = files[0];
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Rocket } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const TrialBanner = () => {
|
||||
const router = useRouter();
|
||||
const { data: billingStatus } = api.stripe.getBillingStatus.useQuery();
|
||||
|
||||
if (!billingStatus?.isOnTrial) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const daysRemaining = billingStatus.trialDaysRemaining ?? 0;
|
||||
|
||||
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."}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-7"
|
||||
onClick={() => router.push("/dashboard/settings/billing")}
|
||||
>
|
||||
Upgrade now
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
@@ -30,6 +31,7 @@ interface Props {
|
||||
|
||||
const AddRedirectSchema = z.object({
|
||||
command: z.string(),
|
||||
pullImages: z.boolean(),
|
||||
});
|
||||
|
||||
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
||||
@@ -57,22 +59,25 @@ export const AddCommandCompose = ({ composeId }: Props) => {
|
||||
const form = useForm<AddCommand>({
|
||||
defaultValues: {
|
||||
command: "",
|
||||
pullImages: false,
|
||||
},
|
||||
resolver: zodResolver(AddRedirectSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.command) {
|
||||
if (data) {
|
||||
form.reset({
|
||||
command: data?.command || "",
|
||||
command: data.command || "",
|
||||
pullImages: data.pullImages ?? false,
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
||||
|
||||
const onSubmit = async (data: AddCommand) => {
|
||||
const onSubmit = async (formData: AddCommand) => {
|
||||
await mutateAsync({
|
||||
composeId,
|
||||
command: data?.command,
|
||||
command: formData.command,
|
||||
pullImages: formData.pullImages,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Command Updated");
|
||||
@@ -109,6 +114,30 @@ export const AddCommandCompose = ({ composeId }: Props) => {
|
||||
<strong>docker</strong>.
|
||||
</AlertBlock>
|
||||
<div className="flex flex-col gap-4">
|
||||
{data?.composeType === "docker-compose" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="pullImages"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-xs">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Pull latest images on deploy</FormLabel>
|
||||
<FormDescription>
|
||||
Adds <strong>--pull always</strong> to the default
|
||||
command so every deploy fetches the newest image for
|
||||
each tag. Has no effect when a custom command is set.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="command"
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { AlertTriangle, Copy, HardDriveDownload } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const freshVolumesSchema = z.object({
|
||||
confirmName: z.string().min(1, {
|
||||
message: "Compose name is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type FreshVolumesForm = z.infer<typeof freshVolumesSchema>;
|
||||
|
||||
interface Props {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
export const FreshVolumes = ({ composeId }: Props) => {
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDeploy = permissions?.deployment.create ?? false;
|
||||
const { data, refetch } = api.compose.one.useQuery(
|
||||
{ composeId },
|
||||
{ enabled: !!composeId },
|
||||
);
|
||||
const { mutateAsync: deploy, isPending } = api.compose.deploy.useMutation();
|
||||
|
||||
const form = useForm<FreshVolumesForm>({
|
||||
defaultValues: { confirmName: "" },
|
||||
resolver: zodResolver(freshVolumesSchema),
|
||||
});
|
||||
|
||||
const expectedName = `${data?.name}/${data?.appName}`;
|
||||
const isRunning = data?.composeStatus === "running";
|
||||
|
||||
const onSubmit = async (formData: FreshVolumesForm) => {
|
||||
if (formData.confirmName !== expectedName) {
|
||||
form.setError("confirmName", {
|
||||
message: `Compose name must match "${expectedName}"`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await deploy({ composeId, freshVolumes: true })
|
||||
.then(() => {
|
||||
toast.success("Compose deployed with fresh volumes");
|
||||
setIsOpen(false);
|
||||
form.reset();
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deploying compose");
|
||||
});
|
||||
};
|
||||
|
||||
if (!canDeploy || data?.composeType !== "docker-compose") return null;
|
||||
|
||||
return (
|
||||
<Card className="bg-background border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
Danger Zone
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-base font-semibold">
|
||||
Deploy with Fresh Volumes
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Permanently wipes every volume of this compose and redeploys it
|
||||
from a clean state. All persistent data (databases, uploads,
|
||||
caches) will be lost.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) form.reset();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
variant="outline"
|
||||
className="w-full border-destructive/50 hover:bg-destructive/10 hover:text-destructive text-destructive"
|
||||
>
|
||||
<HardDriveDownload className="mr-2 h-4 w-4" />
|
||||
Deploy with Fresh Volumes
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
Are you absolutely sure?
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-2">
|
||||
<p>This action will:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Stop the current compose</li>
|
||||
<li>Delete all volumes and their data</li>
|
||||
<li>Redeploy the compose with empty volumes</li>
|
||||
</ul>
|
||||
<p className="font-medium text-destructive mt-4">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-fresh-volumes"
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<span>
|
||||
To confirm, type{" "}
|
||||
<Badge
|
||||
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (data?.name && data?.appName) {
|
||||
copy(expectedName);
|
||||
toast.success(
|
||||
"Copied to clipboard. Be careful!",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{expectedName}
|
||||
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
|
||||
</Badge>{" "}
|
||||
in the box below:
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter compose name to confirm"
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
{isRunning && (
|
||||
<AlertBlock type="warning" className="w-full">
|
||||
Cannot deploy with fresh volumes while a deployment is
|
||||
running. Please wait for it to finish and then try again.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setIsOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
disabled={isRunning}
|
||||
form="hook-form-fresh-volumes"
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
>
|
||||
Wipe volumes and deploy
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
HardDriveDownload,
|
||||
RefreshCcw,
|
||||
Rocket,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
import { toast } from "sonner";
|
||||
@@ -89,51 +82,6 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && data?.composeType === "docker-compose" && (
|
||||
<DialogAction
|
||||
title="Deploy with Fresh Volumes"
|
||||
description="This will remove all volumes and redeploy with a clean state. All persistent data will be permanently deleted."
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deploy({
|
||||
composeId: composeId,
|
||||
freshVolumes: true,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Compose deployed with fresh volumes");
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deploying compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
isLoading={data?.composeStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<HardDriveDownload className="size-4 mr-1" />
|
||||
Fresh Volumes
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Deploy with fresh volumes (removes all persistent data)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Rebuild Compose"
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -56,7 +56,7 @@ export const PlanStep = ({ onNext }: Props) => {
|
||||
try {
|
||||
await startFreeTrial();
|
||||
await utils.project.onboardingStatus.invalidate();
|
||||
toast.success("Your 14-day trial has started");
|
||||
toast.success("Your 7-day trial has started");
|
||||
onNext();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
@@ -90,14 +90,14 @@ export const PlanStep = ({ onNext }: Props) => {
|
||||
Recommended
|
||||
</span>
|
||||
<p className={`${displayFont.className} text-2xl mt-3`}>
|
||||
14-day free trial
|
||||
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">
|
||||
{[
|
||||
"1 server included",
|
||||
"Setup 1 server",
|
||||
"Unlimited apps & databases",
|
||||
"Community support",
|
||||
].map((f) => (
|
||||
@@ -140,7 +140,7 @@ export const PlanStep = ({ onNext }: Props) => {
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5 mt-4">
|
||||
{[
|
||||
"1 server included",
|
||||
"Setup 1 server",
|
||||
"Unlimited apps & databases",
|
||||
"2 environments",
|
||||
"Community support",
|
||||
@@ -183,7 +183,7 @@ export const PlanStep = ({ onNext }: Props) => {
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5 mt-4">
|
||||
{[
|
||||
`${STARTUP_SERVERS_INCLUDED} servers included`,
|
||||
`Setup up to ${STARTUP_SERVERS_INCLUDED} servers`,
|
||||
"Unlimited users & environments",
|
||||
"Basic RBAC + 2FA",
|
||||
"Email & chat support",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon, Plus } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { PenBoxIcon, Plus, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Logo } from "@/components/shared/logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dropzone } from "@/components/ui/dropzone";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -24,6 +27,8 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { resizeImage } from "@/utils/image-processing";
|
||||
import { sanitizeSvg } from "@/utils/sanitize-svg";
|
||||
|
||||
const organizationSchema = z.object({
|
||||
name: z.string().min(1, {
|
||||
@@ -37,10 +42,24 @@ type OrganizationFormValues = z.infer<typeof organizationSchema>;
|
||||
interface Props {
|
||||
organizationId?: string;
|
||||
children?: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function AddOrganization({ organizationId }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
export function AddOrganization({
|
||||
organizationId,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
}: Props) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const uploadCounter = useRef(0);
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
const setOpen = isControlled
|
||||
? controlledOnOpenChange || (() => {})
|
||||
: setInternalOpen;
|
||||
const utils = api.useUtils();
|
||||
const { data: organization } = api.organization.one.useQuery(
|
||||
{
|
||||
@@ -65,14 +84,18 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
if (organization) {
|
||||
uploadCounter.current++;
|
||||
setIsUploading(false);
|
||||
form.reset({
|
||||
name: organization.name,
|
||||
logo: organization.logo || "",
|
||||
});
|
||||
setUploadedFileName(null);
|
||||
}
|
||||
}, [organization, form]);
|
||||
|
||||
const onSubmit = async (values: OrganizationFormValues) => {
|
||||
if (isUploading) return;
|
||||
await mutateAsync({
|
||||
name: values.name,
|
||||
logo: values.logo,
|
||||
@@ -80,6 +103,7 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
})
|
||||
.then(() => {
|
||||
form.reset();
|
||||
setUploadedFileName(null);
|
||||
toast.success(
|
||||
`Organization ${organizationId ? "updated" : "created"} successfully`,
|
||||
);
|
||||
@@ -99,8 +123,94 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileUpload = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
|
||||
const currentUploadId = ++uploadCounter.current;
|
||||
setIsUploading(true);
|
||||
|
||||
const allowedTypes = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/svg+xml",
|
||||
"image/webp",
|
||||
];
|
||||
const fileExtension = file.name.split(".").pop()?.toLowerCase();
|
||||
const allowedExtensions = ["jpg", "jpeg", "png", "svg", "webp"];
|
||||
|
||||
if (
|
||||
!allowedTypes.includes(file.type) &&
|
||||
!allowedExtensions.includes(fileExtension || "")
|
||||
) {
|
||||
toast.error("Only JPG, JPEG, PNG, WEBP, and SVG files are allowed");
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error("Image size must be less than 2MB");
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const isSvg = file.type === "image/svg+xml" || fileExtension === "svg";
|
||||
|
||||
if (isSvg) {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const sanitizedDataUrl = sanitizeSvg(text);
|
||||
if (currentUploadId !== uploadCounter.current) return;
|
||||
if (!sanitizedDataUrl) {
|
||||
toast.error("Invalid SVG file");
|
||||
return;
|
||||
}
|
||||
form.setValue("logo", sanitizedDataUrl);
|
||||
form.trigger("logo");
|
||||
setUploadedFileName(file.name);
|
||||
} catch (error) {
|
||||
if (currentUploadId === uploadCounter.current) {
|
||||
toast.error("Error processing SVG");
|
||||
}
|
||||
} finally {
|
||||
if (currentUploadId === uploadCounter.current) {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Resize raster images to max 256x256 and convert to WebP to save space
|
||||
try {
|
||||
const resizedDataUrl = await resizeImage(file, 256);
|
||||
if (currentUploadId !== uploadCounter.current) return;
|
||||
form.setValue("logo", resizedDataUrl);
|
||||
form.trigger("logo");
|
||||
setUploadedFileName(file.name);
|
||||
} catch (error) {
|
||||
if (currentUploadId === uploadCounter.current) {
|
||||
toast.error("Error processing image");
|
||||
}
|
||||
} finally {
|
||||
if (currentUploadId === uploadCounter.current) {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
if (!val) {
|
||||
uploadCounter.current++;
|
||||
setIsUploading(false);
|
||||
}
|
||||
setOpen(val);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
{organizationId ? (
|
||||
<Button
|
||||
@@ -146,8 +256,10 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="tems-center gap-4">
|
||||
<FormLabel className="text-right">Name</FormLabel>
|
||||
<FormItem className="items-center gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel className="text-right">Name</FormLabel>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Organization name"
|
||||
@@ -162,23 +274,78 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logo"
|
||||
render={({ field }) => (
|
||||
<FormItem className="gap-4">
|
||||
<FormLabel className="text-right">Logo URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/logo.png"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="col-span-3 col-start-2" />
|
||||
</FormItem>
|
||||
)}
|
||||
render={({ field }) => {
|
||||
const isDataUrl = field.value?.startsWith("data:");
|
||||
const displayValue = isDataUrl
|
||||
? uploadedFileName || "Uploaded image"
|
||||
: field.value || "";
|
||||
|
||||
return (
|
||||
<FormItem className="gap-4">
|
||||
<FormLabel className="text-right">
|
||||
Logo URL or Upload
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="col-span-3 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-md border bg-muted/50 overflow-hidden">
|
||||
{field.value ? (
|
||||
// biome-ignore lint/performance/noImgElement: user uploaded logo preview
|
||||
<img
|
||||
src={field.value}
|
||||
alt="Logo preview"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Logo className="size-7" />
|
||||
)}
|
||||
</div>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
placeholder="https://example.com/logo.png"
|
||||
{...field}
|
||||
value={displayValue}
|
||||
readOnly={isDataUrl}
|
||||
onChange={(e) => {
|
||||
uploadCounter.current++;
|
||||
setIsUploading(false);
|
||||
field.onChange(e);
|
||||
if (isDataUrl) setUploadedFileName(null);
|
||||
}}
|
||||
className="w-full pr-8"
|
||||
/>
|
||||
{field.value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
uploadCounter.current++;
|
||||
setIsUploading(false);
|
||||
form.setValue("logo", "");
|
||||
setUploadedFileName(null);
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Dropzone
|
||||
dropMessage="Drag & drop a logo or click to upload"
|
||||
accept=".jpg,.jpeg,.png,.svg,.webp,image/jpeg,image/png,image/svg+xml,image/webp"
|
||||
onChange={handleFileUpload}
|
||||
classNameWrapper="border-2 border-dashed border-border hover:border-primary bg-muted/30 hover:bg-muted/50 transition-all rounded-lg"
|
||||
classNameContent="h-32"
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage className="col-span-3 col-start-2" />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
<Button type="submit" isLoading={isPending || isUploading}>
|
||||
{organizationId ? "Update organization" : "Create organization"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -24,7 +24,7 @@ export const AiForm = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -117,7 +117,7 @@ export const ShowBilling = () => {
|
||||
utils.stripe.getProducts.invalidate(),
|
||||
utils.user.get.invalidate(),
|
||||
]);
|
||||
toast.success("Your 14-day trial has started");
|
||||
toast.success("Your 7-day trial has started");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error starting trial",
|
||||
@@ -326,14 +326,14 @@ export const ShowBilling = () => {
|
||||
<Clock className="h-5 w-5 text-primary shrink-0" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">
|
||||
14-day free trial
|
||||
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">
|
||||
{[
|
||||
"1 server included",
|
||||
"Setup 1 server",
|
||||
"Unlimited apps & databases",
|
||||
"Community support",
|
||||
].map((feature) => (
|
||||
@@ -917,7 +917,7 @@ export const ShowBilling = () => {
|
||||
"Unlimited Deployments",
|
||||
"Unlimited Databases",
|
||||
"Unlimited Applications",
|
||||
"1 Server Included",
|
||||
"Setup 1 Server",
|
||||
"1 Organization",
|
||||
"1 User",
|
||||
"2 Environments",
|
||||
@@ -1049,7 +1049,7 @@ export const ShowBilling = () => {
|
||||
All the features of Hobby, plus…
|
||||
</li>
|
||||
{[
|
||||
"3 Servers Included",
|
||||
"Setup up to 3 Servers",
|
||||
"3 Organizations",
|
||||
"Unlimited Users",
|
||||
"Unlimited Environments",
|
||||
|
||||
@@ -38,7 +38,7 @@ export const ShowCertificates = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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}
|
||||
|
||||
@@ -44,6 +44,18 @@ const providerLabels = {
|
||||
cloudflare: "Cloudflare",
|
||||
route53: "AWS Route53",
|
||||
porkbun: "Porkbun",
|
||||
infomaniak: "Infomaniak",
|
||||
ovh: "OVHcloud",
|
||||
} as const;
|
||||
|
||||
const ovhEndpointLabels = {
|
||||
"ovh-eu": "OVHcloud Europe",
|
||||
"ovh-ca": "OVHcloud Canada",
|
||||
"ovh-us": "OVHcloud US",
|
||||
"kimsufi-eu": "Kimsufi Europe",
|
||||
"kimsufi-ca": "Kimsufi Canada",
|
||||
"soyoustart-eu": "So you Start Europe",
|
||||
"soyoustart-ca": "So you Start Canada",
|
||||
} as const;
|
||||
|
||||
type ProviderType = keyof typeof providerLabels;
|
||||
@@ -55,12 +67,27 @@ const DnsProviderSchema = z.object({
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, {
|
||||
message: "Only letters, numbers, dashes and underscores",
|
||||
}),
|
||||
providerType: z.enum(["cloudflare", "route53", "porkbun"]),
|
||||
providerType: z.enum([
|
||||
"cloudflare",
|
||||
"route53",
|
||||
"porkbun",
|
||||
"infomaniak",
|
||||
"ovh",
|
||||
]),
|
||||
apiToken: z.string(),
|
||||
accessKeyId: z.string(),
|
||||
secretAccessKey: z.string(),
|
||||
apiKey: z.string(),
|
||||
secretApiKey: z.string(),
|
||||
endpoint: z.enum(
|
||||
Object.keys(ovhEndpointLabels) as [
|
||||
keyof typeof ovhEndpointLabels,
|
||||
...(keyof typeof ovhEndpointLabels)[],
|
||||
],
|
||||
),
|
||||
applicationKey: z.string(),
|
||||
applicationSecret: z.string(),
|
||||
consumerKey: z.string(),
|
||||
});
|
||||
|
||||
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
|
||||
@@ -73,6 +100,10 @@ const defaultValues: DnsProviderForm = {
|
||||
secretAccessKey: "",
|
||||
apiKey: "",
|
||||
secretApiKey: "",
|
||||
endpoint: "ovh-eu",
|
||||
applicationKey: "",
|
||||
applicationSecret: "",
|
||||
consumerKey: "",
|
||||
};
|
||||
|
||||
const buildConfig = (data: DnsProviderForm) => {
|
||||
@@ -94,6 +125,19 @@ const buildConfig = (data: DnsProviderForm) => {
|
||||
apiKey: data.apiKey,
|
||||
secretApiKey: data.secretApiKey,
|
||||
};
|
||||
case "infomaniak":
|
||||
return {
|
||||
providerType: "infomaniak" as const,
|
||||
apiToken: data.apiToken,
|
||||
};
|
||||
case "ovh":
|
||||
return {
|
||||
providerType: "ovh" as const,
|
||||
endpoint: data.endpoint,
|
||||
applicationKey: data.applicationKey,
|
||||
applicationSecret: data.applicationSecret,
|
||||
consumerKey: data.consumerKey,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -155,6 +199,15 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
||||
apiKey: provider.config.apiKey,
|
||||
secretApiKey: provider.config.secretApiKey,
|
||||
}),
|
||||
...(provider.config.providerType === "infomaniak" && {
|
||||
apiToken: provider.config.apiToken,
|
||||
}),
|
||||
...(provider.config.providerType === "ovh" && {
|
||||
endpoint: provider.config.endpoint,
|
||||
applicationKey: provider.config.applicationKey,
|
||||
applicationSecret: provider.config.applicationSecret,
|
||||
consumerKey: provider.config.consumerKey,
|
||||
}),
|
||||
});
|
||||
} else if (!dnsProviderId) {
|
||||
form.reset(defaultValues);
|
||||
@@ -383,6 +436,118 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{providerType === "infomaniak" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Create a token at manager.infomaniak.com with the{" "}
|
||||
<code>domain:read</code>, <code>dns:read</code> and{" "}
|
||||
<code>dns:write</code> scopes.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{providerType === "ovh" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endpoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Endpoint</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an endpoint" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.entries(ovhEndpointLabels).map(
|
||||
([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="applicationKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Application Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="applicationSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Application Secret</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="consumerKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Consumer Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Create the three keys at once on
|
||||
api.ovh.com/createToken, with exactly these five rights:
|
||||
<br />
|
||||
<code>GET /domain/zone</code>
|
||||
<br />
|
||||
<code>GET /domain/zone/*</code>
|
||||
<br />
|
||||
<code>POST /domain/zone/*</code>
|
||||
<br />
|
||||
<code>PUT /domain/zone/*</code>
|
||||
<br />
|
||||
<code>DELETE /domain/zone/*</code>
|
||||
<br />
|
||||
The first one lists your zones and has to be granted on
|
||||
its own: OVH matches rights per exact path, so{" "}
|
||||
<code>/domain/zone/*</code> does not cover it.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -23,6 +23,9 @@ import { HandleDnsProvider } from "./handle-dns-provider";
|
||||
const providerLabels: Record<string, string> = {
|
||||
cloudflare: "Cloudflare",
|
||||
route53: "AWS Route53",
|
||||
porkbun: "Porkbun",
|
||||
infomaniak: "Infomaniak",
|
||||
ovh: "OVHcloud",
|
||||
};
|
||||
|
||||
export const ShowDnsProviders = () => {
|
||||
@@ -32,7 +35,7 @@ export const ShowDnsProviders = () => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto">
|
||||
<div className="w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<div className="w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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">
|
||||
|
||||
@@ -150,7 +150,7 @@ export const ProfileForm = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
||||
<div>
|
||||
|
||||
@@ -49,7 +49,7 @@ export const ShowServers = () => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{query?.success && isCloud && <WelcomeSubscription />}
|
||||
<Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
@@ -115,10 +115,13 @@ export const ShowServers = () => {
|
||||
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start justify-between gap-2 min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ServerIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||
<CardTitle className="text-lg wrap-break-word min-w-0">
|
||||
<CardTitle
|
||||
className="text-lg truncate min-w-0"
|
||||
title={server.name}
|
||||
>
|
||||
{server.name}
|
||||
</CardTitle>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ export const ShowDestinations = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -40,6 +40,7 @@ const providerLabels = {
|
||||
hashicorp: "HashiCorp Vault / OpenBao",
|
||||
infisical: "Infisical",
|
||||
aws: "AWS Secrets Manager",
|
||||
"aws-parameter-store": "AWS Parameter Store",
|
||||
doppler: "Doppler",
|
||||
azure: "Azure Key Vault",
|
||||
scaleway: "Scaleway Secret Manager",
|
||||
@@ -61,6 +62,7 @@ const VaultProviderSchema = z
|
||||
"hashicorp",
|
||||
"infisical",
|
||||
"aws",
|
||||
"aws-parameter-store",
|
||||
"doppler",
|
||||
"azure",
|
||||
"scaleway",
|
||||
@@ -79,6 +81,7 @@ const VaultProviderSchema = z
|
||||
region: z.string(),
|
||||
accessKeyId: z.string(),
|
||||
secretAccessKey: z.string(),
|
||||
parameterPath: z.string(),
|
||||
serviceToken: z.string(),
|
||||
awsEndpoint: z.string(),
|
||||
vaultUri: z.string(),
|
||||
@@ -136,7 +139,8 @@ const VaultProviderSchema = z
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.providerType === "aws" &&
|
||||
(data.providerType === "aws" ||
|
||||
data.providerType === "aws-parameter-store") &&
|
||||
data.awsEndpoint &&
|
||||
!isValidUrl(data.awsEndpoint)
|
||||
) {
|
||||
@@ -146,6 +150,17 @@ const VaultProviderSchema = z
|
||||
path: ["awsEndpoint"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.providerType === "aws-parameter-store" &&
|
||||
data.parameterPath &&
|
||||
!data.parameterPath.trim().startsWith("/")
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Parameter discovery path must start with /",
|
||||
path: ["parameterPath"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.providerType === "scaleway" &&
|
||||
data.scalewayApiUrl &&
|
||||
@@ -200,6 +215,11 @@ const VaultProviderSchema = z
|
||||
["accessKeyId", "Access Key ID is required"],
|
||||
["secretAccessKey", "Secret Access Key is required"],
|
||||
],
|
||||
"aws-parameter-store": [
|
||||
["region", "Region is required"],
|
||||
["accessKeyId", "Access Key ID is required"],
|
||||
["secretAccessKey", "Secret Access Key is required"],
|
||||
],
|
||||
doppler: [["serviceToken", "Service Token is required"]],
|
||||
azure: [
|
||||
["vaultUri", "Vault URI is required"],
|
||||
@@ -265,6 +285,7 @@ const defaultValues: VaultProviderForm = {
|
||||
region: "",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
parameterPath: "",
|
||||
serviceToken: "",
|
||||
awsEndpoint: "",
|
||||
vaultUri: "",
|
||||
@@ -313,6 +334,15 @@ const buildConfig = (data: VaultProviderForm) => {
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
endpoint: data.awsEndpoint || undefined,
|
||||
};
|
||||
case "aws-parameter-store":
|
||||
return {
|
||||
providerType: "aws-parameter-store" as const,
|
||||
region: data.region,
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
endpoint: data.awsEndpoint || undefined,
|
||||
parameterPath: data.parameterPath.trim() || undefined,
|
||||
};
|
||||
case "doppler":
|
||||
return {
|
||||
providerType: "doppler" as const,
|
||||
@@ -443,12 +473,16 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
||||
environmentSlug: provider.config.environmentSlug,
|
||||
secretPath: provider.config.secretPath,
|
||||
}),
|
||||
...(provider.config.providerType === "aws" && {
|
||||
...((provider.config.providerType === "aws" ||
|
||||
provider.config.providerType === "aws-parameter-store") && {
|
||||
region: provider.config.region,
|
||||
accessKeyId: provider.config.accessKeyId,
|
||||
secretAccessKey: provider.config.secretAccessKey,
|
||||
awsEndpoint: provider.config.endpoint ?? "",
|
||||
}),
|
||||
...(provider.config.providerType === "aws-parameter-store" && {
|
||||
parameterPath: provider.config.parameterPath ?? "",
|
||||
}),
|
||||
...(provider.config.providerType === "doppler" && {
|
||||
serviceToken: provider.config.serviceToken,
|
||||
dopplerProject: provider.config.project ?? "",
|
||||
@@ -757,7 +791,8 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{providerType === "aws" && (
|
||||
{(providerType === "aws" ||
|
||||
providerType === "aws-parameter-store") && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -815,12 +850,44 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormDescription>
|
||||
Reference format:{" "}
|
||||
<code>{"${{vault.<name>.secret-name}}"}</code> or{" "}
|
||||
<code>{"${{vault.<name>.secret-name:field}}"}</code> for JSON
|
||||
secrets
|
||||
</FormDescription>
|
||||
{providerType === "aws-parameter-store" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="parameterPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Discovery path (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/production/my-app" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Limits parameter browsing and autocomplete to this
|
||||
hierarchy. IAM permissions remain the security
|
||||
boundary.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{providerType === "aws" ? (
|
||||
<FormDescription>
|
||||
Reference format:{" "}
|
||||
<code>{"${{vault.<name>.secret-name}}"}</code> or{" "}
|
||||
<code>{"${{vault.<name>.secret-name:field}}"}</code> for
|
||||
JSON secrets
|
||||
</FormDescription>
|
||||
) : (
|
||||
<FormDescription>
|
||||
Reference format:{" "}
|
||||
<code>{"${{vault.<name>./path/to/parameter}}"}</code>.
|
||||
SecureString parameters are decrypted automatically.
|
||||
Resolution requires <code>ssm:GetParameters</code>.
|
||||
Discovery requires <code>ssm:DescribeParameters</code>.
|
||||
Customer-managed KMS keys also require{" "}
|
||||
<code>kms:Decrypt</code>.
|
||||
</FormDescription>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ const providerLabels: Record<string, string> = {
|
||||
hashicorp: "HashiCorp Vault",
|
||||
infisical: "Infisical",
|
||||
aws: "AWS Secrets Manager",
|
||||
"aws-parameter-store": "AWS Parameter Store",
|
||||
doppler: "Doppler",
|
||||
azure: "Azure Key Vault",
|
||||
scaleway: "Scaleway Secret Manager",
|
||||
@@ -32,7 +33,7 @@ export const ShowVaultProviders = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<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 max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Server,
|
||||
Sparkles,
|
||||
Stars,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
@@ -14,6 +15,7 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@@ -126,13 +128,13 @@ export const UpdateServer = ({
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<DialogTitle className="text-2xl font-semibold">
|
||||
<DialogContent className="max-w-lg" showCloseButton={false}>
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
<DialogTitle className="text-2xl font-semibold mr-auto">
|
||||
Web Server Update
|
||||
</DialogTitle>
|
||||
{dokployVersion && (
|
||||
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 mr-2 bg-muted">
|
||||
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 bg-muted">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{dokployVersion}{" "}
|
||||
@@ -141,6 +143,12 @@ export const UpdateServer = ({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" size="icon-sm" className="shrink-0">
|
||||
<X />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
{/* Initial state */}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import type { ServiceType } from "@dokploy/server/db/schema";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { ArrowRightLeft } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import { type LogLine, parseLogs } from "../docker/logs/utils";
|
||||
|
||||
const LOCAL_SERVER = "dokploy";
|
||||
|
||||
const transferSchema = z.object({
|
||||
targetServerId: z.string().min(1, { message: "Select a target server" }),
|
||||
removeSourceData: z.boolean(),
|
||||
});
|
||||
|
||||
type TransferForm = z.infer<typeof transferSchema>;
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: ServiceType;
|
||||
serverId?: string | null;
|
||||
}
|
||||
|
||||
export const TransferService = ({ id, type, serverId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [isTransferring, setIsTransferring] = useState(false);
|
||||
const [logs, setLogs] = useState<LogLine[]>([]);
|
||||
const [request, setRequest] = useState<{
|
||||
targetServerId: string | null;
|
||||
removeSourceData: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const targets = [
|
||||
...(!isCloud && serverId
|
||||
? [{ serverId: LOCAL_SERVER, name: "Dokploy Server" }]
|
||||
: []),
|
||||
...(servers ?? []).filter((server) => server.serverId !== serverId),
|
||||
];
|
||||
|
||||
const form = useForm<TransferForm>({
|
||||
defaultValues: { targetServerId: "", removeSourceData: false },
|
||||
resolver: zodResolver(transferSchema),
|
||||
});
|
||||
|
||||
api.transfer.start.useSubscription(
|
||||
{
|
||||
serviceType: type,
|
||||
serviceId: id,
|
||||
targetServerId: request?.targetServerId ?? null,
|
||||
removeSourceData: request?.removeSourceData ?? false,
|
||||
},
|
||||
{
|
||||
enabled: isTransferring && request !== null,
|
||||
onData(line) {
|
||||
setLogs((prev) => [...prev, ...parseLogs(line)]);
|
||||
if (line.startsWith("Transfer completed")) {
|
||||
setIsTransferring(false);
|
||||
toast.success("Service transferred successfully");
|
||||
utils.invalidate();
|
||||
} else if (line.startsWith("Transfer failed")) {
|
||||
setIsTransferring(false);
|
||||
toast.error("Transfer failed, check the logs");
|
||||
utils.invalidate();
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
setIsTransferring(false);
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const onSubmit = (values: TransferForm) => {
|
||||
setRequest({
|
||||
targetServerId:
|
||||
values.targetServerId === LOCAL_SERVER ? null : values.targetServerId,
|
||||
removeSourceData: values.removeSourceData,
|
||||
});
|
||||
setLogs([]);
|
||||
setIsDrawerOpen(true);
|
||||
setIsTransferring(true);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
isLoading={isTransferring}
|
||||
>
|
||||
<ArrowRightLeft className="size-4 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Transfer to another server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Moves this service with its volumes, bind mounts, file mounts and
|
||||
configuration to the selected server, then deploys it there.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{targets.length === 0 ? (
|
||||
<AlertBlock type="info">
|
||||
There are no other servers available to transfer this service to.
|
||||
</AlertBlock>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-transfer-service"
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="targetServerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Target server</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{targets.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverId}
|
||||
>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="removeSourceData"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="ml-2">
|
||||
Remove volumes from the source server after the
|
||||
transfer
|
||||
</FormLabel>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<AlertBlock type="warning">
|
||||
<ul className="list-disc pl-4 space-y-1">
|
||||
<li>The service is stopped while its data is copied.</li>
|
||||
<li>
|
||||
Point your DNS records to the new server, certificates are
|
||||
issued again there.
|
||||
</li>
|
||||
<li>
|
||||
Networks that only exist on the current server are
|
||||
detached.
|
||||
</li>
|
||||
<li>
|
||||
Bind mount host paths are copied but never deleted from
|
||||
the source server.
|
||||
</li>
|
||||
</ul>
|
||||
</AlertBlock>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setIsOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{targets.length > 0 && (
|
||||
<Button
|
||||
isLoading={isTransferring}
|
||||
form="hook-form-transfer-service"
|
||||
type="submit"
|
||||
>
|
||||
Transfer
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DrawerLogs
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => setIsDrawerOpen(false)}
|
||||
filteredLogs={logs}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -91,8 +91,32 @@ export const PorkbunIcon = ({ className }: Props) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const InfomaniakIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M2.4 0A2.395 2.395 0 0 0 0 2.4v19.2C0 22.9296 1.0704 24 2.4 24h19.2c1.3296 0 2.4-1.0704 2.4-2.4V2.4C24 1.0704 22.9296 0 21.6 0H10.112v11.7119l3.648-4.128h6l-4.58 4.3506 4.868 8.1296h-5.52l-2.5938-5.0211L10.112 16.8v3.264H5.12V0Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const OvhIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M19.881 10.095l2.563-4.45C23.434 7.389 24 9.404 24 11.555c0 2.88-1.017 5.523-2.71 7.594h-6.62l2.04-3.541h-2.696l3.176-5.513h2.691zm-2.32-5.243L9.333 19.14l.003.009H2.709C1.014 17.077 0 14.435 0 11.555c0-2.152.57-4.17 1.561-5.918L5.855 13.1 10.6 4.852h6.961z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const dnsProviderIcons = {
|
||||
cloudflare: CloudflareIcon,
|
||||
route53: Route53Icon,
|
||||
porkbun: PorkbunIcon,
|
||||
infomaniak: InfomaniakIcon,
|
||||
ovh: OvhIcon,
|
||||
} as const;
|
||||
|
||||
@@ -608,6 +608,7 @@ export const vaultProviderIcons = {
|
||||
hashicorp: HashicorpVaultIcon,
|
||||
infisical: InfisicalIcon,
|
||||
aws: AwsIcon,
|
||||
"aws-parameter-store": AwsIcon,
|
||||
doppler: DopplerIcon,
|
||||
azure: AzureIcon,
|
||||
scaleway: ScalewayIcon,
|
||||
|
||||
@@ -43,6 +43,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { TruncateTooltip } from "@/components/shared/truncate-tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -100,6 +101,7 @@ import { authClient } from "@/lib/auth-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AppRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { TrialBanner } from "../dashboard/billing/trial-banner";
|
||||
import { AddOrganization } from "../dashboard/organization/handle-organization";
|
||||
import { DialogAction } from "../shared/dialog-action";
|
||||
import { Logo } from "../shared/logo";
|
||||
@@ -616,7 +618,7 @@ function SidebarLogo() {
|
||||
)}
|
||||
>
|
||||
{/* Organization Logo and Selector */}
|
||||
<SidebarMenuItem className={"w-full"}>
|
||||
<SidebarMenuItem className={"w-full min-w-0"}>
|
||||
<Popover
|
||||
open={organizationSelectorOpen}
|
||||
onOpenChange={setOrganizationSelectorOpen}
|
||||
@@ -632,14 +634,13 @@ function SidebarLogo() {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
"flex min-w-0 flex-1 items-center gap-2",
|
||||
isCollapsed && "justify-center",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-sm border",
|
||||
"size-6",
|
||||
"flex size-6 shrink-0 items-center justify-center rounded-sm border",
|
||||
)}
|
||||
>
|
||||
<Logo
|
||||
@@ -652,22 +653,27 @@ function SidebarLogo() {
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-start",
|
||||
"flex flex-col items-start min-w-0 flex-1",
|
||||
isCollapsed && "hidden",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{activeOrganization?.name ?? "Select Organization"}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 min-w-0 w-full">
|
||||
<TruncateTooltip
|
||||
text={
|
||||
activeOrganization?.name ?? "Select Organization"
|
||||
}
|
||||
className="text-sm font-medium"
|
||||
/>
|
||||
{haveValidLicense && (
|
||||
<Badge variant="blue">Enterprise</Badge>
|
||||
<Badge variant="blue" className="shrink-0">
|
||||
Enterprise
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronsUpDown
|
||||
className={cn("ml-auto", isCollapsed && "hidden")}
|
||||
className={cn("ml-auto shrink-0", isCollapsed && "hidden")}
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
</PopoverTrigger>
|
||||
@@ -1224,6 +1230,7 @@ export default function Page({ children }: Props) {
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
{isCloud === true && <TrialBanner />}
|
||||
{!includesProjects && (
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
<div className="flex items-center justify-between w-full px-4">
|
||||
|
||||
@@ -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 max-w-5xl mx-auto w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
|
||||
@@ -83,6 +83,11 @@ const azureMapping: ClaimMapping = {
|
||||
image: "",
|
||||
};
|
||||
|
||||
// id: "sub",
|
||||
// email: "preferred_username",
|
||||
// emailVerified: "email_verified",
|
||||
// name: "name",
|
||||
|
||||
const genericMapping: ClaimMapping = {
|
||||
id: "sub",
|
||||
email: "email",
|
||||
@@ -228,10 +233,9 @@ export function RegisterOidcDialog({
|
||||
mapping: {
|
||||
id: oidc?.mapping?.id ?? baseMapping.id,
|
||||
email: oidc?.mapping?.email ?? baseMapping.email,
|
||||
emailVerified:
|
||||
oidc?.mapping?.emailVerified ?? baseMapping.emailVerified,
|
||||
emailVerified: oidc?.mapping?.emailVerified ?? "",
|
||||
name: oidc?.mapping?.name ?? baseMapping.name,
|
||||
image: oidc?.mapping?.image ?? baseMapping.image,
|
||||
image: oidc?.mapping?.image ?? "",
|
||||
},
|
||||
});
|
||||
}, [data, open, form]);
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Head from "next/head";
|
||||
import { useTheme } from "next-themes";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export function WhitelabelingProvider() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const faviconHref =
|
||||
config?.faviconUrl ??
|
||||
(resolvedTheme === "dark"
|
||||
? "/icon-dark.svg"
|
||||
: resolvedTheme === "light"
|
||||
? "/icon-light.svg"
|
||||
: "/icon.svg");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
{config?.metaTitle && <title>{config.metaTitle}</title>}
|
||||
<link rel="icon" href={faviconHref} key="app-favicon" />
|
||||
</Head>
|
||||
|
||||
{config?.customCss && (
|
||||
<style
|
||||
id="whitelabeling-styles"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: config.customCss,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -47,8 +47,8 @@ const formSchema = z.object({
|
||||
docsUrl: safeUrlField,
|
||||
errorPageTitle: z.string(),
|
||||
errorPageDescription: z.string(),
|
||||
metaTitle: z.string(),
|
||||
footerText: z.string(),
|
||||
ogImageUrl: safeUrlField,
|
||||
});
|
||||
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
@@ -193,8 +193,8 @@ export function WhitelabelingSettings() {
|
||||
docsUrl: "",
|
||||
errorPageTitle: "",
|
||||
errorPageDescription: "",
|
||||
metaTitle: "",
|
||||
footerText: "",
|
||||
ogImageUrl: "",
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
@@ -212,8 +212,8 @@ export function WhitelabelingSettings() {
|
||||
docsUrl: data.docsUrl ?? "",
|
||||
errorPageTitle: data.errorPageTitle ?? "",
|
||||
errorPageDescription: data.errorPageDescription ?? "",
|
||||
metaTitle: data.metaTitle ?? "",
|
||||
footerText: data.footerText ?? "",
|
||||
ogImageUrl: data.ogImageUrl ?? "",
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
@@ -242,8 +242,8 @@ export function WhitelabelingSettings() {
|
||||
docsUrl: values.docsUrl || null,
|
||||
errorPageTitle: values.errorPageTitle || null,
|
||||
errorPageDescription: values.errorPageDescription || null,
|
||||
metaTitle: values.metaTitle || null,
|
||||
footerText: values.footerText || null,
|
||||
ogImageUrl: values.ogImageUrl || null,
|
||||
},
|
||||
})
|
||||
.then(async () => {
|
||||
@@ -388,6 +388,27 @@ export function WhitelabelingSettings() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ogImageUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>OG Image URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/og.png"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Open Graph image used for link previews on social media
|
||||
and messaging platforms. Recommended size: 1200x630px.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -441,32 +462,15 @@ export function WhitelabelingSettings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Metadata & Links Section */}
|
||||
{/* Links Section */}
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle>Metadata & Links</CardTitle>
|
||||
<CardTitle>Links</CardTitle>
|
||||
<CardDescription>
|
||||
Customize the page title, footer text, and sidebar links.
|
||||
Customize the footer text and sidebar links.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metaTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Page Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Dokploy" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Browser tab title. Defaults to "Dokploy" if empty.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="footerText"
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
import type React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const TruncateTooltip = ({ text, className, ...props }: Props) => {
|
||||
const textRef = useRef<HTMLParagraphElement>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = textRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const checkTruncation = () => {
|
||||
const truncated = element.scrollWidth > element.clientWidth;
|
||||
setIsTruncated(truncated);
|
||||
if (!truncated) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkTruncation();
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
checkTruncation();
|
||||
});
|
||||
|
||||
resizeObserver.observe(element);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [text]);
|
||||
|
||||
const content = (
|
||||
<p ref={textRef} className={cn("truncate", className)} {...props}>
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip
|
||||
delayDuration={0}
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
// Only allow opening if it's actually truncated
|
||||
if (isTruncated) {
|
||||
setIsOpen(open);
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md max-w-[280px] break-words animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||
>
|
||||
<p>{text}</p>
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -10,13 +10,24 @@ interface DropzoneProps
|
||||
"value" | "onChange"
|
||||
> {
|
||||
classNameWrapper?: string;
|
||||
classNameContent?: string;
|
||||
className?: string;
|
||||
dropMessage: string;
|
||||
onChange: (acceptedFiles: FileList | null) => void;
|
||||
}
|
||||
|
||||
export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
|
||||
({ className, classNameWrapper, dropMessage, onChange, ...props }, ref) => {
|
||||
(
|
||||
{
|
||||
className,
|
||||
classNameWrapper,
|
||||
classNameContent,
|
||||
dropMessage,
|
||||
onChange,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
// Function to handle drag over event
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
@@ -51,7 +62,10 @@ export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
|
||||
)}
|
||||
>
|
||||
<CardContent
|
||||
className="flex flex-col items-center justify-center space-y-2 px-2 py-4 text-xs h-96"
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center space-y-2 px-2 py-4 text-xs h-96",
|
||||
classNameContent,
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleButtonClick}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'infomaniak';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'ovh';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."VaultProviderType" ADD VALUE 'aws-parameter-store' BEFORE 'doppler';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "sso_provider" ADD COLUMN "domain_verified" boolean DEFAULT true NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "webServerSettings" ALTER COLUMN "whitelabelingConfig" SET DEFAULT '{"appName":null,"appDescription":null,"logoUrl":null,"faviconUrl":null,"customCss":null,"loginLogoUrl":null,"supportUrl":null,"docsUrl":null,"errorPageTitle":null,"errorPageDescription":null,"footerText":null,"ogImageUrl":null}'::jsonb;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "compose" ADD COLUMN "pullImages" boolean DEFAULT false NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1338,6 +1338,48 @@
|
||||
"when": 1788300390648,
|
||||
"tag": "0190_nappy_anita_blake",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 191,
|
||||
"version": "7",
|
||||
"when": 1788332224024,
|
||||
"tag": "0191_cool_christian_walker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 192,
|
||||
"version": "7",
|
||||
"when": 1788600069255,
|
||||
"tag": "0192_light_lake",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 193,
|
||||
"version": "7",
|
||||
"when": 1788802431830,
|
||||
"tag": "0193_chemical_the_liberteens",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 194,
|
||||
"version": "7",
|
||||
"when": 1788858506799,
|
||||
"tag": "0194_acoustic_prima",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 195,
|
||||
"version": "7",
|
||||
"when": 1788867370653,
|
||||
"tag": "0195_classy_whirlwind",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 196,
|
||||
"version": "7",
|
||||
"when": 1788995453103,
|
||||
"tag": "0196_worthless_ravenous",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.30.5",
|
||||
"version": "v0.30.6",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -50,6 +50,7 @@
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@aws-sdk/client-route-53": "^3.1108.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.1108.0",
|
||||
"@aws-sdk/client-ssm": "3.1108.0",
|
||||
"@better-auth/api-key": "1.6.23",
|
||||
"@better-auth/passkey": "1.6.23",
|
||||
"@better-auth/scim": "1.6.23",
|
||||
@@ -108,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",
|
||||
@@ -132,7 +133,7 @@
|
||||
"resend": "^6.0.2",
|
||||
"semver": "7.7.3",
|
||||
"shadcn": "^4.11.0",
|
||||
"shell-quote": "^1.8.1",
|
||||
"shell-quote": "^1.8.4",
|
||||
"slugify": "^1.6.6",
|
||||
"sonner": "^1.7.4",
|
||||
"ssh2": "~1.16.0",
|
||||
@@ -179,7 +180,7 @@
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^7.0.2",
|
||||
"vite-tsconfig-paths": "4.3.2",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.25.2"
|
||||
|
||||
@@ -3,12 +3,10 @@ import "@/styles/globals.css";
|
||||
import type { NextPage } from "next";
|
||||
import type { AppProps } from "next/app";
|
||||
import { Inter } from "next/font/google";
|
||||
import Head from "next/head";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { SearchCommand } from "@/components/dashboard/search-command";
|
||||
import { WhitelabelingProvider } from "@/components/proprietary/whitelabeling/whitelabeling-provider";
|
||||
import { Analytics } from "@/components/shared/analytics";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
@@ -40,9 +38,6 @@ const MyApp = ({
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<Head>
|
||||
<title>Dokploy</title>
|
||||
</Head>
|
||||
<TooltipProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
@@ -52,7 +47,6 @@ const MyApp = ({
|
||||
forcedTheme={Component.theme}
|
||||
>
|
||||
<NextTopLoader color="hsl(var(--sidebar-ring))" />
|
||||
<WhitelabelingProvider />
|
||||
<Analytics />
|
||||
<Toaster richColors />
|
||||
<SearchCommand />
|
||||
|
||||
@@ -1,10 +1,56 @@
|
||||
import { Head, Html, Main, NextScript } from "next/document";
|
||||
import { getPublicWhitelabelingConfig } from "@dokploy/server";
|
||||
import NextDocument, {
|
||||
type DocumentContext,
|
||||
type DocumentInitialProps,
|
||||
Head,
|
||||
Html,
|
||||
Main,
|
||||
NextScript,
|
||||
} from "next/document";
|
||||
|
||||
interface WhitelabelingDocumentProps {
|
||||
appName: string | null;
|
||||
appDescription: string | null;
|
||||
ogImageUrl: string | null;
|
||||
faviconHref: string | null;
|
||||
customCss: string | null;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export default function Document({
|
||||
appName,
|
||||
appDescription,
|
||||
ogImageUrl,
|
||||
faviconHref,
|
||||
customCss,
|
||||
baseUrl,
|
||||
}: WhitelabelingDocumentProps) {
|
||||
const title = appName || "Dokploy";
|
||||
const description =
|
||||
appDescription || "The Open Source alternative to Netlify, Vercel, Heroku.";
|
||||
|
||||
let ogImage = ogImageUrl || "/og.png";
|
||||
if (ogImage.startsWith("/")) {
|
||||
ogImage = `${baseUrl}${ogImage}`;
|
||||
}
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="en" className="font-sans">
|
||||
<Head>
|
||||
<link rel="icon" href="/icon.svg" />
|
||||
{/* Rendered on the server so the correct branding is present on first
|
||||
paint (and for social scrapers), avoiding a flash of / fallback to
|
||||
the default Dokploy branding. */}
|
||||
<title>{title}</title>
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<link rel="icon" href={faviconHref || "/icon.svg"} />
|
||||
{customCss && (
|
||||
<style
|
||||
id="whitelabeling-styles"
|
||||
dangerouslySetInnerHTML={{ __html: customCss }}
|
||||
/>
|
||||
)}
|
||||
</Head>
|
||||
<body className="flex h-full w-full flex-col font-sans">
|
||||
<Main />
|
||||
@@ -13,3 +59,83 @@ export default function Document() {
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
const SETTINGS_CACHE_TTL = 60 * 1000; // 1 minute
|
||||
|
||||
declare global {
|
||||
var __SETTINGS_CACHE: {
|
||||
data: {
|
||||
appName: string | null;
|
||||
appDescription: string | null;
|
||||
ogImageUrl: string | null;
|
||||
faviconHref: string | null;
|
||||
customCss: string | null;
|
||||
};
|
||||
expiresAt: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
Document.getInitialProps = async (
|
||||
ctx: DocumentContext,
|
||||
): Promise<DocumentInitialProps & WhitelabelingDocumentProps> => {
|
||||
const initialProps = await NextDocument.getInitialProps(ctx);
|
||||
|
||||
let appName: string | null = null;
|
||||
let appDescription: string | null = null;
|
||||
let ogImageUrl: string | null = null;
|
||||
let faviconHref: string | null = null;
|
||||
let customCss: string | null = null;
|
||||
|
||||
const host = ctx.req?.headers?.host || "localhost:3000";
|
||||
const protocol = ctx.req?.headers?.["x-forwarded-proto"] || "http";
|
||||
const baseUrl = `${protocol}://${host}`;
|
||||
|
||||
if (
|
||||
globalThis.__SETTINGS_CACHE &&
|
||||
globalThis.__SETTINGS_CACHE.expiresAt > Date.now() &&
|
||||
globalThis.__SETTINGS_CACHE.data
|
||||
) {
|
||||
return {
|
||||
...initialProps,
|
||||
...globalThis.__SETTINGS_CACHE.data,
|
||||
baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getPublicWhitelabelingConfig();
|
||||
if (config) {
|
||||
appName = config.appName;
|
||||
appDescription = config.appDescription;
|
||||
ogImageUrl = config.ogImageUrl;
|
||||
// Remove any </style> tags to prevent XSS breakout
|
||||
customCss = config.customCss
|
||||
? config.customCss.replace(/<\/\s*style[^>]*>/gi, "")
|
||||
: null;
|
||||
faviconHref = config.faviconUrl || null;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to defaults if settings can't be read (e.g. DB not ready)
|
||||
}
|
||||
|
||||
globalThis.__SETTINGS_CACHE = {
|
||||
data: {
|
||||
appName,
|
||||
appDescription,
|
||||
ogImageUrl,
|
||||
faviconHref,
|
||||
customCss,
|
||||
},
|
||||
expiresAt: Date.now() + SETTINGS_CACHE_TTL,
|
||||
};
|
||||
|
||||
return {
|
||||
...initialProps,
|
||||
appName,
|
||||
appDescription,
|
||||
ogImageUrl,
|
||||
faviconHref,
|
||||
customCss,
|
||||
baseUrl,
|
||||
};
|
||||
};
|
||||
|
||||
+8
@@ -36,6 +36,7 @@ import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
||||
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
||||
import { AssignNetworks } from "@/components/dashboard/networks/assign-networks";
|
||||
import { TransferService } from "@/components/dashboard/shared/transfer-service";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
@@ -194,6 +195,13 @@ const Service = (
|
||||
{permissions?.service.create && (
|
||||
<UpdateApplication applicationId={applicationId} />
|
||||
)}
|
||||
{permissions?.service.create && (
|
||||
<TransferService
|
||||
id={applicationId}
|
||||
type="application"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.service.delete && (
|
||||
<DeleteService id={applicationId} type="application" />
|
||||
)}
|
||||
|
||||
+10
@@ -23,6 +23,7 @@ import { ShowSchedules } from "@/components/dashboard/application/schedules/show
|
||||
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
||||
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
||||
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
|
||||
import { FreshVolumes } from "@/components/dashboard/compose/advanced/fresh-volumes";
|
||||
import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers";
|
||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
|
||||
@@ -33,6 +34,7 @@ import { ShowBackups } from "@/components/dashboard/database/backups/show-backup
|
||||
import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring";
|
||||
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
|
||||
import { AssignComposeNetworks } from "@/components/dashboard/networks/assign-compose-networks";
|
||||
import { TransferService } from "@/components/dashboard/shared/transfer-service";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
@@ -184,6 +186,13 @@ const Service = (
|
||||
<UpdateCompose composeId={composeId} />
|
||||
)}
|
||||
|
||||
{permissions?.service.create && (
|
||||
<TransferService
|
||||
id={composeId}
|
||||
type="compose"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.service.delete && (
|
||||
<DeleteService id={composeId} type="compose" />
|
||||
)}
|
||||
@@ -434,6 +443,7 @@ const Service = (
|
||||
<ShowImport composeId={composeId} />
|
||||
<AssignComposeNetworks composeId={composeId} />
|
||||
<IsolatedDeploymentTab composeId={composeId} />
|
||||
<FreshVolumes composeId={composeId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
+8
@@ -23,6 +23,7 @@ import { UpdateLibsql } from "@/components/dashboard/libsql/update-libsql";
|
||||
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
||||
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
||||
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
||||
import { TransferService } from "@/components/dashboard/shared/transfer-service";
|
||||
import { LibsqlIcon } from "@/components/icons/data-tools-icons";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
@@ -145,6 +146,13 @@ const Libsql = (
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 justify-end">
|
||||
<UpdateLibsql libsqlId={libsqlId} />
|
||||
{(auth?.role === "owner" || auth?.canCreateServices) && (
|
||||
<TransferService
|
||||
id={libsqlId}
|
||||
type="libsql"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
)}
|
||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
||||
<DeleteService id={libsqlId} type="libsql" />
|
||||
)}
|
||||
|
||||
+8
@@ -23,6 +23,7 @@ import { UpdateMariadb } from "@/components/dashboard/mariadb/update-mariadb";
|
||||
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
||||
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
||||
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
||||
import { TransferService } from "@/components/dashboard/shared/transfer-service";
|
||||
import { MariadbIcon } from "@/components/icons/data-tools-icons";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
@@ -159,6 +160,13 @@ const Mariadb = (
|
||||
{permissions?.service.create && (
|
||||
<UpdateMariadb mariadbId={mariadbId} />
|
||||
)}
|
||||
{permissions?.service.create && (
|
||||
<TransferService
|
||||
id={mariadbId}
|
||||
type="mariadb"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.service.delete && (
|
||||
<DeleteService id={mariadbId} type="mariadb" />
|
||||
)}
|
||||
|
||||
+8
@@ -23,6 +23,7 @@ import { UpdateMongo } from "@/components/dashboard/mongo/update-mongo";
|
||||
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
||||
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
||||
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
||||
import { TransferService } from "@/components/dashboard/shared/transfer-service";
|
||||
import { MongodbIcon } from "@/components/icons/data-tools-icons";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
@@ -159,6 +160,13 @@ const Mongo = (
|
||||
{permissions?.service.create && (
|
||||
<UpdateMongo mongoId={mongoId} />
|
||||
)}
|
||||
{permissions?.service.create && (
|
||||
<TransferService
|
||||
id={mongoId}
|
||||
type="mongo"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.service.delete && (
|
||||
<DeleteService id={mongoId} type="mongo" />
|
||||
)}
|
||||
|
||||
+8
@@ -23,6 +23,7 @@ import { ShowGeneralMysql } from "@/components/dashboard/mysql/general/show-gene
|
||||
import { ShowInternalMysqlCredentials } from "@/components/dashboard/mysql/general/show-internal-mysql-credentials";
|
||||
import { UpdateMysql } from "@/components/dashboard/mysql/update-mysql";
|
||||
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
||||
import { TransferService } from "@/components/dashboard/shared/transfer-service";
|
||||
import { MysqlIcon } from "@/components/icons/data-tools-icons";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
@@ -159,6 +160,13 @@ const MySql = (
|
||||
{permissions?.service.create && (
|
||||
<UpdateMysql mysqlId={mysqlId} />
|
||||
)}
|
||||
{permissions?.service.create && (
|
||||
<TransferService
|
||||
id={mysqlId}
|
||||
type="mysql"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.service.delete && (
|
||||
<DeleteService id={mysqlId} type="mysql" />
|
||||
)}
|
||||
|
||||
+8
@@ -23,6 +23,7 @@ import { ShowGeneralPostgres } from "@/components/dashboard/postgres/general/sho
|
||||
import { ShowInternalPostgresCredentials } from "@/components/dashboard/postgres/general/show-internal-postgres-credentials";
|
||||
import { UpdatePostgres } from "@/components/dashboard/postgres/update-postgres";
|
||||
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
||||
import { TransferService } from "@/components/dashboard/shared/transfer-service";
|
||||
import { PostgresqlIcon } from "@/components/icons/data-tools-icons";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
@@ -158,6 +159,13 @@ const Postgresql = (
|
||||
{permissions?.service.create && (
|
||||
<UpdatePostgres postgresId={postgresId} />
|
||||
)}
|
||||
{permissions?.service.create && (
|
||||
<TransferService
|
||||
id={postgresId}
|
||||
type="postgres"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.service.delete && (
|
||||
<DeleteService id={postgresId} type="postgres" />
|
||||
)}
|
||||
|
||||
+8
@@ -22,6 +22,7 @@ import { ShowGeneralRedis } from "@/components/dashboard/redis/general/show-gene
|
||||
import { ShowInternalRedisCredentials } from "@/components/dashboard/redis/general/show-internal-redis-credentials";
|
||||
import { UpdateRedis } from "@/components/dashboard/redis/update-redis";
|
||||
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
|
||||
import { TransferService } from "@/components/dashboard/shared/transfer-service";
|
||||
import { RedisIcon } from "@/components/icons/data-tools-icons";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
|
||||
@@ -158,6 +159,13 @@ const Redis = (
|
||||
{permissions?.service.create && (
|
||||
<UpdateRedis redisId={redisId} />
|
||||
)}
|
||||
{permissions?.service.create && (
|
||||
<TransferService
|
||||
id={redisId}
|
||||
type="redis"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.service.delete && (
|
||||
<DeleteService id={redisId} type="redis" />
|
||||
)}
|
||||
|
||||
@@ -21,7 +21,7 @@ const Page = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<div className="h-full rounded-xl w-full flex flex-col gap-4">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { appRouter } from "@/server/api/root";
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<div className="h-full rounded-xl w-full flex flex-col gap-4">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<div className="p-6">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user