mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-19 14:15:21 +02:00
Compare commits
1 Commits
main
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2859fd2a9 |
@@ -1,21 +0,0 @@
|
|||||||
# Dockerfile for DevContainer
|
|
||||||
FROM node:24.4.0-bullseye-slim
|
|
||||||
|
|
||||||
# Install essential packages
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
curl \
|
|
||||||
bash \
|
|
||||||
git \
|
|
||||||
&& apt-get clean \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Set up PNPM
|
|
||||||
ENV PNPM_HOME="/pnpm"
|
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
|
||||||
RUN corepack enable && corepack prepare pnpm@10.22.0 --activate
|
|
||||||
|
|
||||||
# Create workspace directory
|
|
||||||
WORKDIR /workspaces/dokploy
|
|
||||||
|
|
||||||
# Set up user permissions
|
|
||||||
USER node
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Dokploy development container",
|
|
||||||
"build": {
|
|
||||||
"dockerfile": "Dockerfile",
|
|
||||||
"context": ".."
|
|
||||||
},
|
|
||||||
"features": {
|
|
||||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
|
||||||
"moby": true,
|
|
||||||
"version": "latest"
|
|
||||||
},
|
|
||||||
"ghcr.io/devcontainers/features/git:1": {
|
|
||||||
"ppa": true,
|
|
||||||
"version": "latest"
|
|
||||||
},
|
|
||||||
"ghcr.io/devcontainers/features/go:1": {
|
|
||||||
"version": "1.20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"customizations": {
|
|
||||||
"vscode": {
|
|
||||||
"extensions": [
|
|
||||||
"ms-vscode.vscode-typescript-next",
|
|
||||||
"bradlc.vscode-tailwindcss",
|
|
||||||
"ms-vscode.vscode-json",
|
|
||||||
"biomejs.biome",
|
|
||||||
"golang.go",
|
|
||||||
"redhat.vscode-xml",
|
|
||||||
"github.vscode-github-actions",
|
|
||||||
"github.copilot",
|
|
||||||
"github.copilot-chat"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"forwardPorts": [3000, 5432, 6379],
|
|
||||||
"portsAttributes": {
|
|
||||||
"3000": {
|
|
||||||
"label": "Dokploy App",
|
|
||||||
"onAutoForward": "notify"
|
|
||||||
},
|
|
||||||
"5432": {
|
|
||||||
"label": "PostgreSQL",
|
|
||||||
"onAutoForward": "silent"
|
|
||||||
},
|
|
||||||
"6379": {
|
|
||||||
"label": "Redis",
|
|
||||||
"onAutoForward": "silent"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"remoteUser": "node",
|
|
||||||
"workspaceFolder": "/workspaces/dokploy",
|
|
||||||
"runArgs": ["--name", "dokploy-devcontainer"]
|
|
||||||
}
|
|
||||||
40
.github/workflows/deploy.yml
vendored
40
.github/workflows/deploy.yml
vendored
@@ -13,17 +13,6 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Set tag and version
|
|
||||||
id: meta-cloud
|
|
||||||
run: |
|
|
||||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
|
||||||
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
@@ -36,7 +25,8 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.cloud
|
file: ./Dockerfile.cloud
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-cloud.outputs.tags }}
|
tags: |
|
||||||
|
siumauricio/cloud:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
build-args: |
|
build-args: |
|
||||||
NEXT_PUBLIC_UMAMI_HOST=${{ secrets.NEXT_PUBLIC_UMAMI_HOST }}
|
NEXT_PUBLIC_UMAMI_HOST=${{ secrets.NEXT_PUBLIC_UMAMI_HOST }}
|
||||||
@@ -50,16 +40,6 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Set tag and version
|
|
||||||
id: meta-schedule
|
|
||||||
run: |
|
|
||||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
|
||||||
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
@@ -72,7 +52,8 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.schedule
|
file: ./Dockerfile.schedule
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-schedule.outputs.tags }}
|
tags: |
|
||||||
|
siumauricio/schedule:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
|
|
||||||
build-and-push-server-image:
|
build-and-push-server-image:
|
||||||
@@ -82,16 +63,6 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Set tag and version
|
|
||||||
id: meta-server
|
|
||||||
run: |
|
|
||||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
|
||||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
|
||||||
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
@@ -104,5 +75,6 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.server
|
file: ./Dockerfile.server
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta-server.outputs.tags }}
|
tags: |
|
||||||
|
siumauricio/server:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
|
|||||||
79
.github/workflows/dokploy.yml
vendored
79
.github/workflows/dokploy.yml
vendored
@@ -138,8 +138,6 @@ jobs:
|
|||||||
needs: [combine-manifests]
|
needs: [combine-manifests]
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
|
||||||
version: ${{ steps.get_version.outputs.version }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -162,80 +160,3 @@ jobs:
|
|||||||
prerelease: false
|
prerelease: false
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
sync-version:
|
|
||||||
needs: [generate-release]
|
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Sync version to MCP repository
|
|
||||||
run: |
|
|
||||||
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.version }}" '.version = $v' package.json > package.json.tmp
|
|
||||||
mv package.json.tmp package.json
|
|
||||||
|
|
||||||
npm install -g pnpm
|
|
||||||
pnpm install
|
|
||||||
pnpm run fetch-openapi
|
|
||||||
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.version }}" \
|
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
|
||||||
--allow-empty
|
|
||||||
git push
|
|
||||||
|
|
||||||
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.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.version }}" '.version = $v' package.json > package.json.tmp
|
|
||||||
mv package.json.tmp package.json
|
|
||||||
|
|
||||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
|
||||||
npm install -g pnpm
|
|
||||||
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.version }}" \
|
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
|
||||||
--allow-empty
|
|
||||||
git push
|
|
||||||
|
|
||||||
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.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.version }}" '.version = $v' package.json > package.json.tmp
|
|
||||||
mv package.json.tmp package.json
|
|
||||||
|
|
||||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
|
||||||
npm install -g pnpm
|
|
||||||
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.version }}" \
|
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
|
||||||
--allow-empty
|
|
||||||
git push
|
|
||||||
|
|
||||||
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}"
|
|
||||||
|
|||||||
2
.github/workflows/pull-request.yml
vendored
2
.github/workflows/pull-request.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
|||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24.4.0
|
node-version: 20.16.0
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
- name: Install Nixpacks
|
- name: Install Nixpacks
|
||||||
|
|||||||
65
.github/workflows/sync-openapi-docs.yml
vendored
65
.github/workflows/sync-openapi-docs.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24.4.0
|
node-version: 20.16.0
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -68,66 +68,3 @@ jobs:
|
|||||||
|
|
||||||
echo "✅ OpenAPI synced to website successfully"
|
echo "✅ OpenAPI synced to website successfully"
|
||||||
|
|
||||||
- name: Sync to MCP repository
|
|
||||||
run: |
|
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo
|
|
||||||
|
|
||||||
cd mcp-repo
|
|
||||||
|
|
||||||
cp -f ../openapi.json openapi.json
|
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
|
||||||
git config user.email "bot@dokploy.com"
|
|
||||||
|
|
||||||
git add openapi.json
|
|
||||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
|
||||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
|
||||||
--allow-empty
|
|
||||||
|
|
||||||
git push
|
|
||||||
|
|
||||||
echo "✅ OpenAPI synced to MCP repository successfully"
|
|
||||||
|
|
||||||
- name: Sync to CLI repository
|
|
||||||
run: |
|
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo
|
|
||||||
|
|
||||||
cd cli-repo
|
|
||||||
|
|
||||||
cp -f ../openapi.json openapi.json
|
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
|
||||||
git config user.email "bot@dokploy.com"
|
|
||||||
|
|
||||||
git add openapi.json
|
|
||||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
|
||||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
|
||||||
--allow-empty
|
|
||||||
|
|
||||||
git push
|
|
||||||
|
|
||||||
echo "✅ OpenAPI synced to CLI repository successfully"
|
|
||||||
|
|
||||||
- name: Sync to SDK repository
|
|
||||||
run: |
|
|
||||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git sdk-repo
|
|
||||||
|
|
||||||
cd sdk-repo
|
|
||||||
|
|
||||||
cp -f ../openapi.json openapi.json
|
|
||||||
|
|
||||||
git config user.name "Dokploy Bot"
|
|
||||||
git config user.email "bot@dokploy.com"
|
|
||||||
|
|
||||||
git add openapi.json
|
|
||||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
|
||||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
|
||||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
|
||||||
--allow-empty
|
|
||||||
|
|
||||||
git push
|
|
||||||
|
|
||||||
echo "✅ OpenAPI synced to SDK repository successfully"
|
|
||||||
|
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -44,3 +44,6 @@ yarn-error.log*
|
|||||||
|
|
||||||
|
|
||||||
.db
|
.db
|
||||||
|
|
||||||
|
# Development environment
|
||||||
|
.devcontainer
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -4,8 +4,5 @@
|
|||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.biome": "explicit",
|
"source.fixAll.biome": "explicit",
|
||||||
"source.organizeImports.biome": "explicit"
|
"source.organizeImports.biome": "explicit"
|
||||||
},
|
|
||||||
"[typescript]": {
|
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ feat: add new feature
|
|||||||
|
|
||||||
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
|
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
|
||||||
|
|
||||||
We use Node v24.4.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 24.4.0 && nvm use` in the root directory.
|
We use Node v20.16.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.16.0 && nvm use` in the root directory.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/dokploy/dokploy.git
|
git clone https://github.com/dokploy/dokploy.git
|
||||||
@@ -99,14 +99,7 @@ pnpm run dokploy:build
|
|||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
To build the docker image first run commands to copy .env files
|
To build the docker image
|
||||||
|
|
||||||
```bash
|
|
||||||
cp apps/dokploy/.env.production.example .env.production
|
|
||||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
|
||||||
```
|
|
||||||
|
|
||||||
then run build command
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm run docker:build
|
pnpm run docker:build
|
||||||
@@ -172,11 +165,10 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.
|
|||||||
|
|
||||||
### Important Considerations for Pull Requests
|
### Important Considerations for Pull Requests
|
||||||
|
|
||||||
- **Testing is Mandatory:** All Pull Requests **must be tested** by the PR author before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested by their creator will be rejected.** This policy keeps the PR history clean and values contributors who submit verified, working code. Untested PRs are often recognizable by disproportionately large or scattered changes for simple tasks—please test first.
|
- **Testing is Mandatory:** All Pull Requests **must be tested** before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested will be closed.** This policy ensures clean contributions and reduces the time maintainers spend reviewing untested or broken code.
|
||||||
- **Focus and Scope:** Each Pull Request should ideally address a single, well-defined problem or introduce one new feature. This greatly facilitates review and reduces the chances of introducing unintended side effects.
|
- **Focus and Scope:** Each Pull Request should ideally address a single, well-defined problem or introduce one new feature. This greatly facilitates review and reduces the chances of introducing unintended side effects.
|
||||||
- **Avoid Unfocused Changes:** Please avoid submitting Pull Requests that contain only minor changes such as whitespace adjustments, IDE-generated formatting, or removal of unused variables, unless these are part of a larger, clearly defined refactor or a dedicated "cleanup" Pull Request that addresses a specific `good first issue` or maintenance task.
|
- **Avoid Unfocused Changes:** Please avoid submitting Pull Requests that contain only minor changes such as whitespace adjustments, IDE-generated formatting, or removal of unused variables, unless these are part of a larger, clearly defined refactor or a dedicated "cleanup" Pull Request that addresses a specific `good first issue` or maintenance task.
|
||||||
- **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`).
|
- **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`).
|
||||||
- **Large Features:** Pull Requests that introduce very large or broad features **will not be accepted** unless the idea is first outlined and discussed in a GitHub issue. Large features should be designed together with the Dokploy team so the project stays coherent and moves in the same direction. Open an issue to propose and align on the design before implementing.
|
|
||||||
|
|
||||||
Thank you for your contribution!
|
Thank you for your contribution!
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
FROM node:24.4.0-slim AS base
|
FROM node:20.16.0-slim AS base
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
RUN corepack prepare pnpm@10.22.0 --activate
|
RUN corepack prepare pnpm@9.12.0 --activate
|
||||||
|
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
COPY . /usr/src/app
|
COPY . /usr/src/app
|
||||||
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
|
|||||||
RUN pnpm --filter=@dokploy/server build
|
RUN pnpm --filter=@dokploy/server build
|
||||||
RUN pnpm --filter=./apps/dokploy run build
|
RUN pnpm --filter=./apps/dokploy run build
|
||||||
|
|
||||||
RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy
|
RUN pnpm --filter=./apps/dokploy --prod deploy /prod/dokploy
|
||||||
|
|
||||||
RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next
|
RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next
|
||||||
RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist
|
RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist
|
||||||
@@ -66,7 +66,7 @@ COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
|
HEALTHCHECK --interval=10s --timeout=3s --retries=10 \
|
||||||
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
||||||
|
|
||||||
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
|
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
FROM node:24.4.0-slim AS base
|
FROM node:20.16.0-slim AS base
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
RUN corepack prepare pnpm@10.22.0 --activate
|
RUN corepack prepare pnpm@9.12.0 --activate
|
||||||
|
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
COPY . /usr/src/app
|
COPY . /usr/src/app
|
||||||
@@ -29,7 +29,7 @@ ENV NODE_ENV=production
|
|||||||
RUN pnpm --filter=@dokploy/server build
|
RUN pnpm --filter=@dokploy/server build
|
||||||
RUN pnpm --filter=./apps/dokploy run build
|
RUN pnpm --filter=./apps/dokploy run build
|
||||||
|
|
||||||
RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy
|
RUN pnpm --filter=./apps/dokploy --prod deploy /prod/dokploy
|
||||||
|
|
||||||
RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next
|
RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next
|
||||||
RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist
|
RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
FROM node:24.4.0-slim AS base
|
FROM node:20.16.0-slim AS base
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
RUN corepack prepare pnpm@10.22.0 --activate
|
RUN corepack prepare pnpm@9.12.0 --activate
|
||||||
|
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
COPY . /usr/src/app
|
COPY . /usr/src/app
|
||||||
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
|
|||||||
RUN pnpm --filter=@dokploy/server build
|
RUN pnpm --filter=@dokploy/server build
|
||||||
RUN pnpm --filter=./apps/schedules run build
|
RUN pnpm --filter=./apps/schedules run build
|
||||||
|
|
||||||
RUN pnpm --filter=./apps/schedules --prod deploy --legacy /prod/schedules
|
RUN pnpm --filter=./apps/schedules --prod deploy /prod/schedules
|
||||||
|
|
||||||
RUN cp -R /usr/src/app/apps/schedules/dist /prod/schedules/dist
|
RUN cp -R /usr/src/app/apps/schedules/dist /prod/schedules/dist
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
FROM node:24.4.0-slim AS base
|
FROM node:20.16.0-slim AS base
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
RUN corepack prepare pnpm@10.22.0 --activate
|
RUN corepack prepare pnpm@9.12.0 --activate
|
||||||
|
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
COPY . /usr/src/app
|
COPY . /usr/src/app
|
||||||
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
|
|||||||
RUN pnpm --filter=@dokploy/server build
|
RUN pnpm --filter=@dokploy/server build
|
||||||
RUN pnpm --filter=./apps/api run build
|
RUN pnpm --filter=./apps/api run build
|
||||||
|
|
||||||
RUN pnpm --filter=./apps/api --prod deploy --legacy /prod/api
|
RUN pnpm --filter=./apps/api --prod deploy /prod/api
|
||||||
|
|
||||||
RUN cp -R /usr/src/app/apps/api/dist /prod/api/dist
|
RUN cp -R /usr/src/app/apps/api/dist /prod/api/dist
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies th
|
|||||||
Dokploy includes multiple features to make your life easier.
|
Dokploy includes multiple features to make your life easier.
|
||||||
|
|
||||||
- **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.).
|
- **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.).
|
||||||
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, libsql, and Redis.
|
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, and Redis.
|
||||||
- **Backups**: Automate backups for databases to an external storage destination.
|
- **Backups**: Automate backups for databases to an external storage destination.
|
||||||
- **Docker Compose**: Native support for Docker Compose to manage complex applications.
|
- **Docker Compose**: Native support for Docker Compose to manage complex applications.
|
||||||
- **Multi Node**: Scale applications to multiple nodes using Docker Swarm to manage the cluster.
|
- **Multi Node**: Scale applications to multiple nodes using Docker Swarm to manage the cluster.
|
||||||
@@ -39,7 +39,7 @@ To get started, run the following command on a VPS:
|
|||||||
Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com).
|
Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sSL https://dokploy.com/install.sh | bash
|
curl -sSL https://dokploy.com/install.sh | sh
|
||||||
```
|
```
|
||||||
|
|
||||||
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
||||||
|
|||||||
@@ -1,11 +1,2 @@
|
|||||||
LEMON_SQUEEZY_API_KEY=""
|
LEMON_SQUEEZY_API_KEY=""
|
||||||
LEMON_SQUEEZY_STORE_ID=""
|
LEMON_SQUEEZY_STORE_ID=""
|
||||||
|
|
||||||
# Inngest (for GET /jobs - list deployment queue). Self-hosted example:
|
|
||||||
# INNGEST_BASE_URL="http://localhost:8288"
|
|
||||||
# Production: INNGEST_BASE_URL="https://dev-inngest.dokploy.com"
|
|
||||||
# INNGEST_SIGNING_KEY="your-signing-key"
|
|
||||||
# Optional: only events after this RFC3339 timestamp. If unset, no date filter is applied.
|
|
||||||
# INNGEST_EVENTS_RECEIVED_AFTER="2024-01-01T00:00:00Z"
|
|
||||||
# Max events to fetch when listing jobs (paginates with cursor). Default 100, max 10000.
|
|
||||||
# INNGEST_JOBS_MAX_EVENTS=100
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "PORT=4000 tsx watch src/index.ts",
|
"dev": "PORT=4000 tsx watch src/index.ts",
|
||||||
"build": "rimraf dist && tsc --project tsconfig.json",
|
"build": "tsc --project tsconfig.json",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"inngest": "3.40.1",
|
"inngest": "3.40.1",
|
||||||
"@dokploy/server": "workspace:*",
|
"@dokploy/server": "workspace:*",
|
||||||
"@hono/node-server": "^1.14.3",
|
"@hono/node-server": "^1.14.3",
|
||||||
"@hono/zod-validator": "0.7.6",
|
"@hono/zod-validator": "0.3.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"hono": "^4.11.7",
|
"hono": "^4.11.7",
|
||||||
"pino": "9.4.0",
|
"pino": "9.4.0",
|
||||||
@@ -20,19 +20,18 @@
|
|||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"redis": "4.7.0",
|
"redis": "4.7.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^3.25.32"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.4.0",
|
"@types/node": "^20.16.0",
|
||||||
"@types/react": "^18.2.37",
|
"@types/react": "^18.2.37",
|
||||||
"@types/react-dom": "^18.2.15",
|
"@types/react-dom": "^18.2.15",
|
||||||
"rimraf": "6.1.3",
|
|
||||||
"tsx": "^4.16.2",
|
"tsx": "^4.16.2",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.22.0",
|
"packageManager": "pnpm@9.12.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^24.4.0",
|
"node": "^20.16.0",
|
||||||
"pnpm": ">=10.22.0"
|
"pnpm": ">=9.12.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
type DeployJob,
|
type DeployJob,
|
||||||
deployJobSchema,
|
deployJobSchema,
|
||||||
} from "./schema.js";
|
} from "./schema.js";
|
||||||
import { fetchDeploymentJobs } from "./service.js";
|
|
||||||
import { deploy } from "./utils.js";
|
import { deploy } from "./utils.js";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -119,6 +118,7 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
|
|||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.log("error", error);
|
||||||
logger.error("Failed to send deployment event", error);
|
logger.error("Failed to send deployment event", error);
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
@@ -176,29 +176,6 @@ app.get("/health", async (c) => {
|
|||||||
return c.json({ status: "ok" });
|
return c.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// List deployment jobs (Inngest runs) for a server - same shape as BullMQ queue for the UI
|
|
||||||
app.get("/jobs", async (c) => {
|
|
||||||
const serverId = c.req.query("serverId");
|
|
||||||
if (!serverId) {
|
|
||||||
return c.json({ message: "serverId is required" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rows = await fetchDeploymentJobs(serverId);
|
|
||||||
return c.json(rows);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
if (message.includes("INNGEST_BASE_URL")) {
|
|
||||||
return c.json(
|
|
||||||
{ message: "INNGEST_BASE_URL is required to list deployment jobs" },
|
|
||||||
503,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
logger.error("Failed to fetch jobs from Inngest", { serverId, error });
|
|
||||||
return c.json([], 200);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Serve Inngest functions endpoint
|
// Serve Inngest functions endpoint
|
||||||
app.on(
|
app.on(
|
||||||
["GET", "POST", "PUT"],
|
["GET", "POST", "PUT"],
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
import { logger } from "./logger.js";
|
|
||||||
|
|
||||||
const baseUrl = process.env.INNGEST_BASE_URL ?? "";
|
|
||||||
const signingKey = process.env.INNGEST_SIGNING_KEY ?? "";
|
|
||||||
|
|
||||||
const DEFAULT_MAX_EVENTS = 500;
|
|
||||||
const MAX_EVENTS = DEFAULT_MAX_EVENTS;
|
|
||||||
|
|
||||||
/** Event shape from GET /v1/events (https://api.inngest.com/v1/events) */
|
|
||||||
type InngestEventRow = {
|
|
||||||
internal_id?: string;
|
|
||||||
accountID?: string;
|
|
||||||
environmentID?: string;
|
|
||||||
source?: string;
|
|
||||||
sourceID?: string | null;
|
|
||||||
/** RFC3339 timestamp – API uses receivedAt, dev server may use received_at */
|
|
||||||
receivedAt?: string;
|
|
||||||
received_at?: string;
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
user?: unknown;
|
|
||||||
ts: number;
|
|
||||||
v?: string | null;
|
|
||||||
metadata?: {
|
|
||||||
fetchedAt: string;
|
|
||||||
cachedUntil: string | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Run shape from GET /v1/events/{eventId}/runs – the actual job execution */
|
|
||||||
type InngestRun = {
|
|
||||||
run_id: string;
|
|
||||||
event_id: string;
|
|
||||||
status: string; // "Running" | "Completed" | "Failed" | "Cancelled" | "Queued"?
|
|
||||||
run_started_at?: string;
|
|
||||||
ended_at?: string | null;
|
|
||||||
output?: unknown;
|
|
||||||
// dev server / API may use different casing
|
|
||||||
run_started_at_ms?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getEventReceivedAt(ev: InngestEventRow): string | undefined {
|
|
||||||
return ev.receivedAt ?? ev.received_at;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map Inngest run status to BullMQ-style state for the UI */
|
|
||||||
function runStatusToState(
|
|
||||||
status: string,
|
|
||||||
): "pending" | "active" | "completed" | "failed" | "cancelled" {
|
|
||||||
const s = status.toLowerCase();
|
|
||||||
if (s === "running") return "active";
|
|
||||||
if (s === "completed") return "completed";
|
|
||||||
if (s === "failed") return "failed";
|
|
||||||
if (s === "cancelled") return "cancelled";
|
|
||||||
if (s === "queued") return "pending";
|
|
||||||
return "pending";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fetchInngestEvents = async () => {
|
|
||||||
const maxEvents = MAX_EVENTS;
|
|
||||||
const all: InngestEventRow[] = [];
|
|
||||||
let cursor: string | undefined;
|
|
||||||
|
|
||||||
do {
|
|
||||||
const params = new URLSearchParams({ limit: "100" });
|
|
||||||
if (cursor) {
|
|
||||||
params.set("cursor", cursor);
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}/v1/events?${params}`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${signingKey}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.warn("Inngest API error", {
|
|
||||||
status: res.status,
|
|
||||||
body: await res.text(),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = (await res.json()) as {
|
|
||||||
data?: InngestEventRow[];
|
|
||||||
cursor?: string;
|
|
||||||
nextCursor?: string;
|
|
||||||
};
|
|
||||||
const data = Array.isArray(body.data) ? body.data : [];
|
|
||||||
all.push(...data);
|
|
||||||
|
|
||||||
// Next page: API may return cursor/nextCursor, or use last event's internal_id (per API docs)
|
|
||||||
const nextCursor =
|
|
||||||
body.cursor ?? body.nextCursor ?? data[data.length - 1]?.internal_id;
|
|
||||||
const hasMore = data.length === 100 && nextCursor && all.length < maxEvents;
|
|
||||||
cursor = hasMore ? nextCursor : undefined;
|
|
||||||
} while (cursor);
|
|
||||||
|
|
||||||
return all.slice(0, maxEvents);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Fetch runs for a single event (GET /v1/events/{eventId}/runs) – runs are the actual jobs */
|
|
||||||
export const fetchInngestRunsForEvent = async (
|
|
||||||
eventId: string,
|
|
||||||
): Promise<InngestRun[]> => {
|
|
||||||
const res = await fetch(
|
|
||||||
`${baseUrl}/v1/events/${encodeURIComponent(eventId)}/runs`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${signingKey}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.warn("Inngest runs API error", {
|
|
||||||
eventId,
|
|
||||||
status: res.status,
|
|
||||||
body: await res.text(),
|
|
||||||
});
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const body = (await res.json()) as { data?: InngestRun[] };
|
|
||||||
return Array.isArray(body.data) ? body.data : [];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** One row for the queue UI (BullMQ-compatible shape) */
|
|
||||||
export type DeploymentJobRow = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
timestamp: number;
|
|
||||||
processedOn?: number;
|
|
||||||
finishedOn?: number;
|
|
||||||
failedReason?: string;
|
|
||||||
state: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Build queue rows from events + their runs (one row per run, or pending if no run yet) */
|
|
||||||
function buildDeploymentRowsFromRuns(
|
|
||||||
events: InngestEventRow[],
|
|
||||||
runsByEventId: Map<string, InngestRun[]>,
|
|
||||||
serverId: string,
|
|
||||||
): DeploymentJobRow[] {
|
|
||||||
const requested = events.filter(
|
|
||||||
(e) =>
|
|
||||||
e.name === "deployment/requested" &&
|
|
||||||
(e.data as Record<string, unknown>)?.serverId === serverId,
|
|
||||||
);
|
|
||||||
const rows: DeploymentJobRow[] = [];
|
|
||||||
|
|
||||||
for (const ev of requested) {
|
|
||||||
const data = (ev.data ?? {}) as Record<string, unknown>;
|
|
||||||
const runs = runsByEventId.get(ev.id) ?? [];
|
|
||||||
|
|
||||||
if (runs.length === 0) {
|
|
||||||
// Queued: event received but no run yet
|
|
||||||
rows.push({
|
|
||||||
id: ev.id,
|
|
||||||
name: ev.name,
|
|
||||||
data,
|
|
||||||
timestamp: ev.ts,
|
|
||||||
processedOn: ev.ts,
|
|
||||||
finishedOn: undefined,
|
|
||||||
failedReason: undefined,
|
|
||||||
state: "pending",
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const run of runs) {
|
|
||||||
const state = runStatusToState(run.status);
|
|
||||||
const runStartedMs =
|
|
||||||
run.run_started_at_ms ??
|
|
||||||
(run.run_started_at ? new Date(run.run_started_at).getTime() : ev.ts);
|
|
||||||
const endedMs = run.ended_at
|
|
||||||
? new Date(run.ended_at).getTime()
|
|
||||||
: undefined;
|
|
||||||
const failedReason =
|
|
||||||
state === "failed" &&
|
|
||||||
run.output &&
|
|
||||||
typeof run.output === "object" &&
|
|
||||||
"error" in run.output
|
|
||||||
? String((run.output as { error?: unknown }).error)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
rows.push({
|
|
||||||
id: run.run_id,
|
|
||||||
name: ev.name,
|
|
||||||
data,
|
|
||||||
timestamp: runStartedMs,
|
|
||||||
processedOn: runStartedMs,
|
|
||||||
finishedOn:
|
|
||||||
state === "completed" || state === "failed" || state === "cancelled"
|
|
||||||
? endedMs
|
|
||||||
: undefined,
|
|
||||||
failedReason,
|
|
||||||
state,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch deployment jobs for a server: events → runs → rows (correct model: runs = jobs) */
|
|
||||||
export const fetchDeploymentJobs = async (
|
|
||||||
serverId: string,
|
|
||||||
): Promise<DeploymentJobRow[]> => {
|
|
||||||
if (!signingKey) {
|
|
||||||
logger.warn("INNGEST_SIGNING_KEY not set, returning empty jobs list");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (!baseUrl) {
|
|
||||||
throw new Error("INNGEST_BASE_URL is required to list deployment jobs");
|
|
||||||
}
|
|
||||||
|
|
||||||
const events = await fetchInngestEvents();
|
|
||||||
|
|
||||||
const requestedForServer = events.filter(
|
|
||||||
(e) =>
|
|
||||||
e.name === "deployment/requested" &&
|
|
||||||
(e.data as Record<string, unknown>)?.serverId === serverId,
|
|
||||||
);
|
|
||||||
// Limit to avoid too many run fetches
|
|
||||||
const toFetch = requestedForServer.slice(0, 50);
|
|
||||||
const runsByEventId = new Map<string, InngestRun[]>();
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
toFetch.map(async (ev) => {
|
|
||||||
const runs = await fetchInngestRunsForEvent(ev.id);
|
|
||||||
runsByEventId.set(ev.id, runs);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return buildDeploymentRowsFromRuns(toFetch, runsByEventId, serverId);
|
|
||||||
};
|
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
updateCompose,
|
updateCompose,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import type { DeployJob } from "./schema.js";
|
import type { DeployJob } from "./schema";
|
||||||
|
|
||||||
export const deploy = async (job: DeployJob) => {
|
export const deploy = async (job: DeployJob) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
1
apps/dokploy/.nvmrc
Normal file
1
apps/dokploy/.nvmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
20.16.0
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
// Isolate the command builder from the compose-file I/O performed by
|
|
||||||
// writeDomainsToCompose; we only care about the docker invocation it emits.
|
|
||||||
vi.mock("@dokploy/server/utils/docker/domain", () => ({
|
|
||||||
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const baseCompose = {
|
|
||||||
appName: "my-app",
|
|
||||||
sourceType: "raw",
|
|
||||||
command: "",
|
|
||||||
composePath: "docker-compose.yml",
|
|
||||||
composeType: "stack",
|
|
||||||
isolatedDeployment: false,
|
|
||||||
randomize: false,
|
|
||||||
suffix: "",
|
|
||||||
serverId: null,
|
|
||||||
env: "",
|
|
||||||
mounts: [],
|
|
||||||
domains: [],
|
|
||||||
environment: { project: { env: "" }, env: "" },
|
|
||||||
} as unknown as Parameters<typeof getBuildComposeCommand>[0];
|
|
||||||
|
|
||||||
// Regression coverage for #4401: the deploy command runs under `env -i`, which
|
|
||||||
// clears the environment except for the vars listed explicitly. HOME must be
|
|
||||||
// preserved so docker can resolve ~/.docker/config.json — otherwise
|
|
||||||
// `docker stack deploy --with-registry-auth` ships no credentials to the swarm
|
|
||||||
// and private-registry images fail to pull.
|
|
||||||
describe("getBuildComposeCommand registry auth (#4401)", () => {
|
|
||||||
it("preserves HOME for swarm stack deploys", async () => {
|
|
||||||
const command = await getBuildComposeCommand({
|
|
||||||
...baseCompose,
|
|
||||||
composeType: "stack",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(command).toContain("stack deploy");
|
|
||||||
expect(command).toContain("--with-registry-auth");
|
|
||||||
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves HOME for docker compose deploys", async () => {
|
|
||||||
const command = await getBuildComposeCommand({
|
|
||||||
...baseCompose,
|
|
||||||
composeType: "docker-compose",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(command).toContain("compose -p my-app");
|
|
||||||
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -32,9 +32,6 @@ describe("Host rule format regression tests", () => {
|
|||||||
previewDeploymentId: "",
|
previewDeploymentId: "",
|
||||||
internalPath: "/",
|
internalPath: "/",
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
customEntrypoint: null,
|
|
||||||
middlewares: null,
|
|
||||||
forwardAuthEnabled: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Host rule format validation", () => {
|
describe("Host rule format validation", () => {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ describe("createDomainLabels", () => {
|
|||||||
const baseDomain: Domain = {
|
const baseDomain: Domain = {
|
||||||
host: "example.com",
|
host: "example.com",
|
||||||
port: 8080,
|
port: 8080,
|
||||||
customEntrypoint: null,
|
|
||||||
https: false,
|
https: false,
|
||||||
uniqueConfigKey: 1,
|
uniqueConfigKey: 1,
|
||||||
customCertResolver: null,
|
customCertResolver: null,
|
||||||
@@ -22,8 +21,6 @@ describe("createDomainLabels", () => {
|
|||||||
previewDeploymentId: "",
|
previewDeploymentId: "",
|
||||||
internalPath: "/",
|
internalPath: "/",
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
middlewares: null,
|
|
||||||
forwardAuthEnabled: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should create basic labels for web entrypoint", async () => {
|
it("should create basic labels for web entrypoint", async () => {
|
||||||
@@ -104,51 +101,6 @@ describe("createDomainLabels", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should add tls=true for certificateType none on websecure entrypoint", async () => {
|
|
||||||
const noneDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
certificateType: "none" as const,
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(appName, noneDomain, "websecure");
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-websecure.tls=true",
|
|
||||||
);
|
|
||||||
// no cert resolver should be set when relying on a default/custom cert
|
|
||||||
expect(labels).not.toContain(
|
|
||||||
"traefik.http.routers.test-app-1-websecure.tls.certresolver=letsencrypt",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not add tls=true for certificateType none on web entrypoint", async () => {
|
|
||||||
const noneDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
certificateType: "none" as const,
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(appName, noneDomain, "web");
|
|
||||||
expect(labels).not.toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.tls=true",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add tls=true for certificateType none on a custom https entrypoint", async () => {
|
|
||||||
const noneDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
customEntrypoint: "websecure-custom",
|
|
||||||
certificateType: "none" as const,
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
noneDomain,
|
|
||||||
"websecure-custom",
|
|
||||||
);
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-websecure-custom.tls=true",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle different ports correctly", async () => {
|
it("should handle different ports correctly", async () => {
|
||||||
const customPortDomain = { ...baseDomain, port: 3000 };
|
const customPortDomain = { ...baseDomain, port: 3000 };
|
||||||
const labels = await createDomainLabels(appName, customPortDomain, "web");
|
const labels = await createDomainLabels(appName, customPortDomain, "web");
|
||||||
@@ -219,12 +171,12 @@ describe("createDomainLabels", () => {
|
|||||||
"websecure",
|
"websecure",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Web entrypoint with HTTPS should only have redirect
|
// Web entrypoint should have both middlewares with redirect first
|
||||||
expect(webLabels).toContain(
|
expect(webLabels).toContain(
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,addprefix-test-app-1",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Websecure should have the addprefix middleware
|
// Websecure should only have the addprefix middleware
|
||||||
expect(websecureLabels).toContain(
|
expect(websecureLabels).toContain(
|
||||||
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
|
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
|
||||||
);
|
);
|
||||||
@@ -256,9 +208,9 @@ describe("createDomainLabels", () => {
|
|||||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Web router with HTTPS should only have redirect
|
// Should have middlewares in correct order: redirect, stripprefix, addprefix
|
||||||
expect(webLabels).toContain(
|
expect(webLabels).toContain(
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,stripprefix-test-app-1,addprefix-test-app-1",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -288,259 +240,4 @@ describe("createDomainLabels", () => {
|
|||||||
"traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
|
"traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should add single custom middleware to router", async () => {
|
|
||||||
const customMiddlewareDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
middlewares: ["auth@file"],
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
customMiddlewareDomain,
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=auth@file",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add multiple custom middlewares to router", async () => {
|
|
||||||
const customMiddlewareDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
middlewares: ["auth@file", "rate-limit@file"],
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
customMiddlewareDomain,
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=auth@file,rate-limit@file",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should only have redirect on web router when HTTPS is enabled with custom middlewares", async () => {
|
|
||||||
const combinedDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
middlewares: ["auth@file"],
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(appName, combinedDomain, "web");
|
|
||||||
|
|
||||||
// Web router with HTTPS should only redirect, custom middlewares go on websecure
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
|
||||||
);
|
|
||||||
expect(labels).not.toContain("auth@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should combine custom middlewares with stripPath middleware (no HTTPS)", async () => {
|
|
||||||
const combinedDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
path: "/api",
|
|
||||||
stripPath: true,
|
|
||||||
middlewares: ["auth@file"],
|
|
||||||
};
|
|
||||||
const labels = await createDomainLabels(appName, combinedDomain, "web");
|
|
||||||
|
|
||||||
// stripprefix should come before custom middleware
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=stripprefix-test-app-1,auth@file",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should only have redirect on web router even with all built-in middlewares and custom middlewares", async () => {
|
|
||||||
const fullDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
path: "/api",
|
|
||||||
stripPath: true,
|
|
||||||
internalPath: "/hello",
|
|
||||||
middlewares: ["auth@file", "rate-limit@file"],
|
|
||||||
};
|
|
||||||
const webLabels = await createDomainLabels(appName, fullDomain, "web");
|
|
||||||
|
|
||||||
// Web router with HTTPS should only redirect
|
|
||||||
expect(webLabels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
|
||||||
);
|
|
||||||
// Middleware definitions should still be present (Traefik needs them registered)
|
|
||||||
expect(webLabels).toContain(
|
|
||||||
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
|
|
||||||
);
|
|
||||||
expect(webLabels).toContain(
|
|
||||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
|
||||||
);
|
|
||||||
// But they should NOT be attached to the router
|
|
||||||
expect(webLabels).not.toContain("stripprefix-test-app-1,");
|
|
||||||
expect(webLabels).not.toContain("auth@file");
|
|
||||||
expect(webLabels).not.toContain("rate-limit@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should include custom middlewares on websecure entrypoint", async () => {
|
|
||||||
const customMiddlewareDomain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
middlewares: ["auth@file"],
|
|
||||||
};
|
|
||||||
const websecureLabels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
customMiddlewareDomain,
|
|
||||||
"websecure",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Websecure should have custom middleware but not redirect-to-https
|
|
||||||
expect(websecureLabels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-websecure.middlewares=auth@file",
|
|
||||||
);
|
|
||||||
expect(websecureLabels).not.toContain("redirect-to-https");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should NOT include custom middlewares on web router when HTTPS is enabled (only redirect)", async () => {
|
|
||||||
const domain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
middlewares: ["rate-limit@file", "auth@file"],
|
|
||||||
};
|
|
||||||
const webLabels = await createDomainLabels(appName, domain, "web");
|
|
||||||
|
|
||||||
// Web router with HTTPS should ONLY have redirect, not custom middlewares
|
|
||||||
expect(webLabels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
|
|
||||||
);
|
|
||||||
expect(webLabels).not.toContain("rate-limit@file");
|
|
||||||
expect(webLabels).not.toContain("auth@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create basic labels for custom entrypoint", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{ ...baseDomain, customEntrypoint: "custom" },
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
expect(labels).toEqual([
|
|
||||||
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
|
|
||||||
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
|
|
||||||
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
|
|
||||||
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create https labels for custom entrypoint", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
certificateType: "letsencrypt",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
expect(labels).toEqual([
|
|
||||||
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
|
|
||||||
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
|
|
||||||
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
|
|
||||||
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
|
|
||||||
"traefik.http.routers.test-app-1-custom.tls.certresolver=letsencrypt",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add stripPath middleware for custom entrypoint", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
path: "/api",
|
|
||||||
stripPath: true,
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
|
|
||||||
);
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add internalPath middleware for custom entrypoint", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
internalPath: "/hello",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
|
||||||
);
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-custom.middlewares=addprefix-test-app-1",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add path prefix in rule for custom entrypoint", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
path: "/api",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`) && PathPrefix(`/api`)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should combine all middlewares for custom entrypoint", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
path: "/api",
|
|
||||||
stripPath: true,
|
|
||||||
internalPath: "/hello",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
|
|
||||||
);
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
|
|
||||||
);
|
|
||||||
expect(labels).toContain(
|
|
||||||
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not add redirect-to-https for custom entrypoint even with https", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
appName,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
https: true,
|
|
||||||
certificateType: "letsencrypt",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
const middlewareLabel = labels.find((l) => l.includes(".middlewares="));
|
|
||||||
// Should not contain redirect-to-https since there's only one router
|
|
||||||
expect(middlewareLabel).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ networks:
|
|||||||
dokploy-network:
|
dokploy-network:
|
||||||
`;
|
`;
|
||||||
|
|
||||||
test("It shouldn't add suffix to dokploy-network", () => {
|
test("It shoudn't add suffix to dokploy-network", () => {
|
||||||
const composeData = parse(composeFile7) as ComposeSpecification;
|
const composeData = parse(composeFile7) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ services:
|
|||||||
- dokploy-network
|
- dokploy-network
|
||||||
`;
|
`;
|
||||||
|
|
||||||
test("It shouldn't add suffix to dokploy-network in services", () => {
|
test("It shoudn't add suffix to dokploy-network in services", () => {
|
||||||
const composeData = parse(composeFile7) as ComposeSpecification;
|
const composeData = parse(composeFile7) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
@@ -244,7 +244,7 @@ services:
|
|||||||
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
test("It shouldn't add suffix to dokploy-network in services multiples cases", () => {
|
test("It shoudn't add suffix to dokploy-network in services multiples cases", () => {
|
||||||
const composeData = parse(composeFile8) as ComposeSpecification;
|
const composeData = parse(composeFile8) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|||||||
@@ -14,18 +14,13 @@ vi.mock("@dokploy/server/db", () => {
|
|||||||
set: vi.fn(() => chain),
|
set: vi.fn(() => chain),
|
||||||
where: vi.fn(() => chain),
|
where: vi.fn(() => chain),
|
||||||
returning: vi.fn().mockResolvedValue([{}] as any),
|
returning: vi.fn().mockResolvedValue([{}] as any),
|
||||||
from: vi.fn(() => chain),
|
|
||||||
innerJoin: vi.fn(() => chain),
|
|
||||||
then: (resolve: (v: any) => void) => {
|
|
||||||
resolve([]);
|
|
||||||
},
|
|
||||||
} as any;
|
} as any;
|
||||||
return chain;
|
return chain;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db: {
|
db: {
|
||||||
select: vi.fn(() => createChainableMock()),
|
select: vi.fn(),
|
||||||
insert: vi.fn(),
|
insert: vi.fn(),
|
||||||
update: vi.fn(() => createChainableMock()),
|
update: vi.fn(() => createChainableMock()),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
@@ -33,12 +28,6 @@ vi.mock("@dokploy/server/db", () => {
|
|||||||
applications: {
|
applications: {
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
},
|
},
|
||||||
patch: {
|
|
||||||
findMany: vi.fn().mockResolvedValue([]),
|
|
||||||
},
|
|
||||||
member: {
|
|
||||||
findMany: vi.fn().mockResolvedValue([]),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,18 +15,13 @@ vi.mock("@dokploy/server/db", () => {
|
|||||||
set: vi.fn(() => chain),
|
set: vi.fn(() => chain),
|
||||||
where: vi.fn(() => chain),
|
where: vi.fn(() => chain),
|
||||||
returning: vi.fn().mockResolvedValue([{}]),
|
returning: vi.fn().mockResolvedValue([{}]),
|
||||||
from: vi.fn(() => chain),
|
|
||||||
innerJoin: vi.fn(() => chain),
|
|
||||||
then: (resolve: (v: any) => void) => {
|
|
||||||
resolve([]);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
return chain;
|
return chain;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db: {
|
db: {
|
||||||
select: vi.fn(() => createChainableMock()),
|
select: vi.fn(),
|
||||||
insert: vi.fn(),
|
insert: vi.fn(),
|
||||||
update: vi.fn(() => createChainableMock()),
|
update: vi.fn(() => createChainableMock()),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
@@ -34,12 +29,6 @@ vi.mock("@dokploy/server/db", () => {
|
|||||||
applications: {
|
applications: {
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
},
|
},
|
||||||
patch: {
|
|
||||||
findMany: vi.fn().mockResolvedValue([]),
|
|
||||||
},
|
|
||||||
member: {
|
|
||||||
findMany: vi.fn().mockResolvedValue([]),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -83,14 +83,6 @@ describe("GitHub Webhook Skip CI", () => {
|
|||||||
{ commits: [{ message: "[skip ci] test" }] },
|
{ commits: [{ message: "[skip ci] test" }] },
|
||||||
),
|
),
|
||||||
).toBe("[skip ci] test");
|
).toBe("[skip ci] test");
|
||||||
|
|
||||||
// Soft Serve
|
|
||||||
expect(
|
|
||||||
extractCommitMessage(
|
|
||||||
{ "x-softserve-event": "push" },
|
|
||||||
{ commits: [{ message: "[skip ci] test" }] },
|
|
||||||
),
|
|
||||||
).toBe("[skip ci] test");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle missing commit message", () => {
|
it("should handle missing commit message", () => {
|
||||||
@@ -107,9 +99,6 @@ describe("GitHub Webhook Skip CI", () => {
|
|||||||
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
|
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
|
||||||
"NEW COMMIT",
|
"NEW COMMIT",
|
||||||
);
|
);
|
||||||
expect(extractCommitMessage({ "x-softserve-event": "push" }, {})).toBe(
|
|
||||||
"NEW COMMIT",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -415,24 +404,5 @@ describe("Docker Image Name and Tag Extraction", () => {
|
|||||||
expect(extractImageTag("my-image:123")).toBe("123");
|
expect(extractImageTag("my-image:123")).toBe("123");
|
||||||
expect(extractImageTag("my-image:1")).toBe("1");
|
expect(extractImageTag("my-image:1")).toBe("1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return 'latest' for registry with port but no tag", () => {
|
|
||||||
expect(extractImageTag("registry.example.com:5000/myimage")).toBe(
|
|
||||||
"latest",
|
|
||||||
);
|
|
||||||
expect(extractImageTag("registry:5000/fedora/httpd")).toBe("latest");
|
|
||||||
expect(extractImageTag("localhost:5000/myapp")).toBe("latest");
|
|
||||||
expect(extractImageTag("my-registry.io:443/org/app")).toBe("latest");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should extract tag from registry with port and tag", () => {
|
|
||||||
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
|
|
||||||
expect(extractImageTag("registry.example.com:5000/myimage:v2.0")).toBe(
|
|
||||||
"v2.0",
|
|
||||||
);
|
|
||||||
expect(extractImageTag("localhost:5000/app:sha-abc123")).toBe(
|
|
||||||
"sha-abc123",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { shouldDeploy } from "@dokploy/server";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
describe("shouldDeploy", () => {
|
|
||||||
it("should deploy when no watch paths are configured", () => {
|
|
||||||
expect(shouldDeploy(null, ["src/index.ts"])).toBe(true);
|
|
||||||
expect(shouldDeploy([], ["src/index.ts"])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should deploy when watch paths match modified files", () => {
|
|
||||||
expect(shouldDeploy(["src/**"], ["src/index.ts"])).toBe(true);
|
|
||||||
expect(shouldDeploy(["apps/web/**"], ["apps/web/page.tsx"])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not deploy when watch paths do not match", () => {
|
|
||||||
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not throw when modified files contain non-string values", () => {
|
|
||||||
expect(() =>
|
|
||||||
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
|
||||||
).not.toThrow();
|
|
||||||
expect(
|
|
||||||
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not throw when modified files are undefined or null", () => {
|
|
||||||
expect(() => shouldDeploy(["src/**"], undefined)).not.toThrow();
|
|
||||||
expect(() => shouldDeploy(["src/**"], null)).not.toThrow();
|
|
||||||
expect(shouldDeploy(["src/**"], undefined)).toBe(false);
|
|
||||||
expect(shouldDeploy(["src/**"], null)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not throw when every modified file is non-string", () => {
|
|
||||||
expect(() =>
|
|
||||||
shouldDeploy(["src/**"], [undefined, undefined] as any),
|
|
||||||
).not.toThrow();
|
|
||||||
expect(shouldDeploy(["src/**"], [undefined, undefined] as any)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
extractBranchName,
|
|
||||||
extractCommitMessage,
|
|
||||||
extractHash,
|
|
||||||
getProviderByHeader,
|
|
||||||
} from "@/pages/api/deploy/[refreshToken]";
|
|
||||||
|
|
||||||
describe("Soft Serve Webhook", () => {
|
|
||||||
const mockSoftServeHeaders = {
|
|
||||||
"x-softserve-event": "push",
|
|
||||||
};
|
|
||||||
|
|
||||||
const createMockBody = (message: string, hash: string, branch: string) => ({
|
|
||||||
event: "push",
|
|
||||||
ref: `refs/heads/${branch}`,
|
|
||||||
after: hash,
|
|
||||||
commits: [{ message: message }],
|
|
||||||
});
|
|
||||||
const message: string = "feat: add new feature";
|
|
||||||
const hash: string = "3c91c24ef9560bddc695bce138bf8a7094ec3df5";
|
|
||||||
const branch: string = "feat/add-new";
|
|
||||||
const goodWebhook = createMockBody(message, hash, branch);
|
|
||||||
|
|
||||||
it("should properly extract the provider name", () => {
|
|
||||||
expect(getProviderByHeader(mockSoftServeHeaders)).toBe("soft-serve");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should properly extract the commit message", () => {
|
|
||||||
expect(extractCommitMessage(mockSoftServeHeaders, goodWebhook)).toBe(
|
|
||||||
message,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should properly extract hash", () => {
|
|
||||||
expect(extractHash(mockSoftServeHeaders, goodWebhook)).toBe(hash);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should properly extract branch name", () => {
|
|
||||||
expect(extractBranchName(mockSoftServeHeaders, goodWebhook)).toBe(branch);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should gracefully handle invalid webhook", () => {
|
|
||||||
expect(getProviderByHeader({})).toBeNull();
|
|
||||||
expect(extractCommitMessage(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
|
|
||||||
expect(extractHash(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
|
|
||||||
expect(extractBranchName(mockSoftServeHeaders, {})).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,7 +6,6 @@ import { paths } from "@dokploy/server/constants";
|
|||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const OUTPUT_BASE = "./__test__/drop/zips/output";
|
|
||||||
const { APPLICATIONS_PATH } = paths();
|
const { APPLICATIONS_PATH } = paths();
|
||||||
vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
||||||
const actual = await importOriginal();
|
const actual = await importOriginal();
|
||||||
@@ -14,10 +13,7 @@ vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
...actual,
|
...actual,
|
||||||
paths: () => ({
|
paths: () => ({
|
||||||
// @ts-ignore
|
APPLICATIONS_PATH: "./__test__/drop/zips/output",
|
||||||
...actual.paths(),
|
|
||||||
BASE_PATH: OUTPUT_BASE,
|
|
||||||
APPLICATIONS_PATH: OUTPUT_BASE,
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -120,7 +116,6 @@ const baseApp: ApplicationNested = {
|
|||||||
environmentId: "",
|
environmentId: "",
|
||||||
enabled: null,
|
enabled: null,
|
||||||
env: null,
|
env: null,
|
||||||
icon: null,
|
|
||||||
healthCheckSwarm: null,
|
healthCheckSwarm: null,
|
||||||
labelsSwarm: null,
|
labelsSwarm: null,
|
||||||
memoryLimit: null,
|
memoryLimit: null,
|
||||||
@@ -155,176 +150,6 @@ const baseApp: ApplicationNested = {
|
|||||||
ulimitsSwarm: null,
|
ulimitsSwarm: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* GHSA-66v7-g3fh-47h3: Remote Code Execution through Path Traversal.
|
|
||||||
* Validates the exact PoC: ZIP with path traversal entry ../../../../../etc/cron.d/malicious-cron
|
|
||||||
* plus cover files (package.json, index.js). unzipDrop must reject and never write outside output.
|
|
||||||
*/
|
|
||||||
describe("GHSA-66v7-g3fh-47h3 path traversal RCE", () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
afterAll(async () => {
|
|
||||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects PoC ZIP: traversal ../../../../../etc/cron.d/malicious-cron + package.json + index.js", async () => {
|
|
||||||
baseApp.appName = "ghsa-rce";
|
|
||||||
// PoC payload: same entry name as advisory (Python zipfile keeps it; AdmZip normalizes on add → use placeholder + replace)
|
|
||||||
const traversalEntry = "../../../../../etc/cron.d/malicious-cron";
|
|
||||||
const cronPayload = "* * * * * root id\n";
|
|
||||||
const placeholder = "x".repeat(traversalEntry.length);
|
|
||||||
const zip = new AdmZip();
|
|
||||||
zip.addFile(
|
|
||||||
"package.json",
|
|
||||||
Buffer.from('{"name": "app", "version": "1.0.0"}'),
|
|
||||||
);
|
|
||||||
zip.addFile("index.js", Buffer.from('console.log("Application");'));
|
|
||||||
zip.addFile(placeholder, Buffer.from(cronPayload));
|
|
||||||
let buf = Buffer.from(zip.toBuffer());
|
|
||||||
buf = Buffer.from(
|
|
||||||
buf.toString("binary").split(placeholder).join(traversalEntry),
|
|
||||||
"binary",
|
|
||||||
);
|
|
||||||
const file = new File([buf as unknown as ArrayBuffer], "exploit.zip");
|
|
||||||
await expect(unzipDrop(file, baseApp)).rejects.toThrow(
|
|
||||||
/Path traversal detected.*resolved path escapes output directory/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("security: existing symlink escape", () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should NOT write outside base when directory is a symlink", async () => {
|
|
||||||
const appName = "symlink-existing";
|
|
||||||
const output = path.join(APPLICATIONS_PATH, appName, "code");
|
|
||||||
await fs.mkdir(output, { recursive: true });
|
|
||||||
|
|
||||||
// outside target (attacker wants to write here)
|
|
||||||
const outside = path.join(APPLICATIONS_PATH, "..", "outside");
|
|
||||||
await fs.mkdir(outside, { recursive: true });
|
|
||||||
|
|
||||||
// attacker-controlled symlink inside project
|
|
||||||
await fs.symlink(outside, path.join(output, "logs"));
|
|
||||||
|
|
||||||
// zip looks totally harmless
|
|
||||||
const zip = new AdmZip();
|
|
||||||
zip.addFile("logs/pwned.txt", Buffer.from("owned"));
|
|
||||||
|
|
||||||
const file = new File([zip.toBuffer() as any], "exploit.zip");
|
|
||||||
|
|
||||||
await unzipDrop(file, { ...baseApp, appName });
|
|
||||||
|
|
||||||
// if vulnerable -> file exists outside sandbox
|
|
||||||
const escaped = await fs
|
|
||||||
.readFile(path.join(outside, "pwned.txt"), "utf8")
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
expect(escaped).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("security: zip symlink entry blocked", () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects zip containing real symlink entry", async () => {
|
|
||||||
const appName = "zip-symlink";
|
|
||||||
|
|
||||||
const zipBuffer = await fs.readFile(
|
|
||||||
path.join(__dirname, "./zips/payload/symlink-entry.zip"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const file = new File([zipBuffer as any], "exploit.zip");
|
|
||||||
|
|
||||||
await expect(unzipDrop(file, { ...baseApp, appName })).rejects.toThrow(
|
|
||||||
/Dangerous node entries are not allowed/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("unzipDrop path under output (no traversal)", () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
afterAll(async () => {
|
|
||||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows entry etc/cron.d/malicious-cron when under output (no path traversal)", async () => {
|
|
||||||
baseApp.appName = "cron-under-output";
|
|
||||||
const zip = new AdmZip();
|
|
||||||
zip.addFile(
|
|
||||||
"etc/cron.d/malicious-cron",
|
|
||||||
Buffer.from("* * * * * root id\n"),
|
|
||||||
);
|
|
||||||
zip.addFile("package.json", Buffer.from('{"name":"app"}'));
|
|
||||||
const file = new File(
|
|
||||||
[zip.toBuffer() as unknown as ArrayBuffer],
|
|
||||||
"app.zip",
|
|
||||||
);
|
|
||||||
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
|
||||||
await unzipDrop(file, baseApp);
|
|
||||||
const content = await fs.readFile(
|
|
||||||
path.join(outputPath, "etc/cron.d/malicious-cron"),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
expect(content).toBe("* * * * * root id\n");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("security: traversal inside BASE_PATH (sandbox escape)", () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should NOT allow writing outside application directory but inside BASE_PATH", async () => {
|
|
||||||
const appName = "sandbox-escape";
|
|
||||||
|
|
||||||
const base = APPLICATIONS_PATH.replace("/applications", "");
|
|
||||||
const output = path.join(APPLICATIONS_PATH, appName, "code");
|
|
||||||
|
|
||||||
await fs.mkdir(output, { recursive: true });
|
|
||||||
|
|
||||||
// attacker writes into traefik config inside base
|
|
||||||
const zip = new AdmZip();
|
|
||||||
zip.addFile(
|
|
||||||
"../../../traefik/dynamic/evil.yml",
|
|
||||||
Buffer.from("pwned: true"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const file = new File([zip.toBuffer() as any], "exploit.zip");
|
|
||||||
|
|
||||||
await unzipDrop(file, { ...baseApp, appName });
|
|
||||||
|
|
||||||
const escapedPath = path.join(base, "traefik/dynamic/evil.yml");
|
|
||||||
|
|
||||||
const exists = await fs
|
|
||||||
.readFile(escapedPath)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
expect(exists).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("unzipDrop using real zip files", () => {
|
describe("unzipDrop using real zip files", () => {
|
||||||
// const { APPLICATIONS_PATH } = paths();
|
// const { APPLICATIONS_PATH } = paths();
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -341,12 +166,14 @@ describe("unzipDrop using real zip files", () => {
|
|||||||
try {
|
try {
|
||||||
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||||
const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
|
const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
|
||||||
|
console.log(`Output Path: ${outputPath}`);
|
||||||
const zipBuffer = zip.toBuffer() as Buffer<ArrayBuffer>;
|
const zipBuffer = zip.toBuffer() as Buffer<ArrayBuffer>;
|
||||||
const file = new File([zipBuffer], "single.zip");
|
const file = new File([zipBuffer], "single.zip");
|
||||||
await unzipDrop(file, baseApp);
|
await unzipDrop(file, baseApp);
|
||||||
const files = await fs.readdir(outputPath, { withFileTypes: true });
|
const files = await fs.readdir(outputPath, { withFileTypes: true });
|
||||||
expect(files.some((f) => f.name === "test.txt")).toBe(true);
|
expect(files.some((f) => f.name === "test.txt")).toBe(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
} finally {
|
} finally {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
/etc/passwd
|
|
||||||
Binary file not shown.
@@ -1,4 +1,4 @@
|
|||||||
import { getEnvironmentVariablesObject } from "@dokploy/server/index";
|
import { getEnviromentVariablesObject } from "@dokploy/server/index";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const projectEnv = `
|
const projectEnv = `
|
||||||
@@ -15,7 +15,7 @@ DATABASE_NAME=dev_database
|
|||||||
SECRET_KEY=env-secret-123
|
SECRET_KEY=env-secret-123
|
||||||
`;
|
`;
|
||||||
|
|
||||||
describe("getEnvironmentVariablesObject with environment variables (Stack compose)", () => {
|
describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => {
|
||||||
it("resolves environment variables correctly for Stack compose", () => {
|
it("resolves environment variables correctly for Stack compose", () => {
|
||||||
const serviceEnv = `
|
const serviceEnv = `
|
||||||
FOO=\${{environment.NODE_ENV}}
|
FOO=\${{environment.NODE_ENV}}
|
||||||
@@ -23,7 +23,7 @@ BAR=\${{environment.API_URL}}
|
|||||||
BAZ=test
|
BAZ=test
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(
|
const result = getEnviromentVariablesObject(
|
||||||
serviceEnv,
|
serviceEnv,
|
||||||
projectEnv,
|
projectEnv,
|
||||||
environmentEnv,
|
environmentEnv,
|
||||||
@@ -45,7 +45,7 @@ DATABASE_URL=\${{project.DATABASE_URL}}
|
|||||||
SERVICE_PORT=4000
|
SERVICE_PORT=4000
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(
|
const result = getEnviromentVariablesObject(
|
||||||
serviceEnv,
|
serviceEnv,
|
||||||
projectEnv,
|
projectEnv,
|
||||||
environmentEnv,
|
environmentEnv,
|
||||||
@@ -72,7 +72,7 @@ PASSWORD=secret123
|
|||||||
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
|
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(serviceEnv, "", multiRefEnv);
|
const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
|
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
|
||||||
@@ -85,7 +85,7 @@ UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
expect(() =>
|
expect(() =>
|
||||||
getEnvironmentVariablesObject(serviceWithUndefined, "", environmentEnv),
|
getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv),
|
||||||
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
|
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ NODE_ENV=production
|
|||||||
API_URL=\${{environment.API_URL}}
|
API_URL=\${{environment.API_URL}}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(
|
const result = getEnviromentVariablesObject(
|
||||||
serviceOverrideEnv,
|
serviceOverrideEnv,
|
||||||
"",
|
"",
|
||||||
environmentEnv,
|
environmentEnv,
|
||||||
@@ -115,7 +115,7 @@ SERVICE_NAME=my-service
|
|||||||
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
|
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(
|
const result = getEnviromentVariablesObject(
|
||||||
complexServiceEnv,
|
complexServiceEnv,
|
||||||
projectEnv,
|
projectEnv,
|
||||||
environmentEnv,
|
environmentEnv,
|
||||||
@@ -150,7 +150,7 @@ ENV_VAR=\${{environment.API_URL}}
|
|||||||
DB_NAME=\${{environment.DATABASE_NAME}}
|
DB_NAME=\${{environment.DATABASE_NAME}}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(
|
const result = getEnviromentVariablesObject(
|
||||||
serviceWithConflicts,
|
serviceWithConflicts,
|
||||||
conflictingProjectEnv,
|
conflictingProjectEnv,
|
||||||
conflictingEnvironmentEnv,
|
conflictingEnvironmentEnv,
|
||||||
@@ -170,7 +170,7 @@ SERVICE_VAR=test
|
|||||||
PROJECT_VAR=\${{project.ENVIRONMENT}}
|
PROJECT_VAR=\${{project.ENVIRONMENT}}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = getEnvironmentVariablesObject(
|
const result = getEnviromentVariablesObject(
|
||||||
serviceWithEmpty,
|
serviceWithEmpty,
|
||||||
projectEnv,
|
projectEnv,
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -1,369 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import {
|
|
||||||
canEditDeployGitSource,
|
|
||||||
getAccessibleGitProviderIds,
|
|
||||||
} from "@dokploy/server/services/git-provider";
|
|
||||||
|
|
||||||
const mockDb = vi.hoisted(() => ({
|
|
||||||
query: {
|
|
||||||
gitProvider: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
},
|
|
||||||
member: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/db", () => ({ db: mockDb }));
|
|
||||||
|
|
||||||
const mockHasValidLicense = vi.hoisted(() => vi.fn());
|
|
||||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
|
||||||
hasValidLicense: mockHasValidLicense,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const ORG_ID = "org-1";
|
|
||||||
const USER_OWNER = "user-owner";
|
|
||||||
const USER_ADMIN = "user-admin";
|
|
||||||
const USER_MEMBER = "user-member";
|
|
||||||
const USER_MEMBER_2 = "user-member-2";
|
|
||||||
|
|
||||||
const providerOwned = {
|
|
||||||
gitProviderId: "gp-owned",
|
|
||||||
userId: USER_MEMBER,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
};
|
|
||||||
const providerShared = {
|
|
||||||
gitProviderId: "gp-shared",
|
|
||||||
userId: USER_OWNER,
|
|
||||||
sharedWithOrganization: true,
|
|
||||||
};
|
|
||||||
const providerPrivate = {
|
|
||||||
gitProviderId: "gp-private",
|
|
||||||
userId: USER_OWNER,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
};
|
|
||||||
const providerOtherMember = {
|
|
||||||
gitProviderId: "gp-other",
|
|
||||||
userId: USER_MEMBER_2,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const allProviders = [
|
|
||||||
providerOwned,
|
|
||||||
providerShared,
|
|
||||||
providerPrivate,
|
|
||||||
providerOtherMember,
|
|
||||||
];
|
|
||||||
|
|
||||||
function session(userId: string) {
|
|
||||||
return { userId, activeOrganizationId: ORG_ID };
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockDb.query.gitProvider.findMany.mockResolvedValue(allProviders);
|
|
||||||
mockHasValidLicense.mockResolvedValue(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getAccessibleGitProviderIds", () => {
|
|
||||||
describe("owner", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "owner",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns all org providers", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
|
|
||||||
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes providers owned by other members", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
|
|
||||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
|
||||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("admin", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "admin",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns all org providers", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
|
||||||
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes providers owned by other members — fixes issue #4469", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
|
||||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("member without enterprise license", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [providerPrivate.gitProviderId],
|
|
||||||
});
|
|
||||||
mockHasValidLicense.mockResolvedValue(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can access their own provider", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can access shared providers", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot access private providers of other users even if assigned (no license)", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot access providers of other members", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("member with enterprise license", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockHasValidLicense.mockResolvedValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can access provider explicitly assigned to them", async () => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [providerPrivate.gitProviderId],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot access provider not assigned and not shared", async () => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
|
||||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can access shared provider even without explicit assignment", async () => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can access own provider regardless of assignments", async () => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot access provider of other member even with license but no assignment", async () => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("member with no member record", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue(null);
|
|
||||||
mockHasValidLicense.mockResolvedValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("only returns own providers and shared ones", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
|
|
||||||
expect(ids.has(providerShared.gitProviderId)).toBe(true);
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("enterprise license — member assigned to a provider they do not own", () => {
|
|
||||||
// getAccessibleGitProviderIds still returns the provider (member can connect NEW deploys)
|
|
||||||
it("member assigned to owner's private provider can USE the provider for new deploys", async () => {
|
|
||||||
mockHasValidLicense.mockResolvedValue(true);
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [providerPrivate.gitProviderId],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member NOT assigned to owner's private provider cannot use it at all", async () => {
|
|
||||||
mockHasValidLicense.mockResolvedValue(true);
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "member",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
|
|
||||||
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("empty org", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.gitProvider.findMany.mockResolvedValue([]);
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({
|
|
||||||
role: "admin",
|
|
||||||
accessedGitProviders: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty set when org has no providers", async () => {
|
|
||||||
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
|
|
||||||
expect(ids.size).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("canEditDeployGitSource", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockHasValidLicense.mockResolvedValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("owner", () => {
|
|
||||||
it("can edit deploy using any provider", async () => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({ role: "owner" });
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerPrivate.gitProviderId,
|
|
||||||
session(USER_OWNER),
|
|
||||||
);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("admin", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({ role: "admin" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot edit deploy using owner's private provider (not shared)", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_OWNER,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerPrivate.gitProviderId,
|
|
||||||
session(USER_ADMIN),
|
|
||||||
);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can edit deploy using a provider shared with the org", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_OWNER,
|
|
||||||
sharedWithOrganization: true,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerShared.gitProviderId,
|
|
||||||
session(USER_ADMIN),
|
|
||||||
);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can edit deploy using their own provider", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_ADMIN,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
"gp-admin-owned",
|
|
||||||
session(USER_ADMIN),
|
|
||||||
);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("member", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockDb.query.member.findFirst.mockResolvedValue({ role: "member" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can edit deploy using their own provider", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_MEMBER,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerOwned.gitProviderId,
|
|
||||||
session(USER_MEMBER),
|
|
||||||
);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("can edit deploy using a provider shared with the org", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_OWNER,
|
|
||||||
sharedWithOrganization: true,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerShared.gitProviderId,
|
|
||||||
session(USER_MEMBER),
|
|
||||||
);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot edit deploy using owner's private provider even with enterprise license and assignment", async () => {
|
|
||||||
// This is the key case: enterprise, provider del owner, no compartido,
|
|
||||||
// member tiene accessedGitProviders asignado — pero NO puede cambiar la branch del deploy del owner
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_OWNER,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerPrivate.gitProviderId,
|
|
||||||
session(USER_MEMBER),
|
|
||||||
);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cannot edit deploy using another member's private provider", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue({
|
|
||||||
userId: USER_MEMBER_2,
|
|
||||||
sharedWithOrganization: false,
|
|
||||||
});
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
providerOtherMember.gitProviderId,
|
|
||||||
session(USER_MEMBER),
|
|
||||||
);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false if provider does not exist", async () => {
|
|
||||||
mockDb.query.gitProvider.findFirst.mockResolvedValue(null);
|
|
||||||
const result = await canEditDeployGitSource(
|
|
||||||
"nonexistent-id",
|
|
||||||
session(USER_MEMBER),
|
|
||||||
);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const mockMemberData = (
|
|
||||||
role: string,
|
|
||||||
overrides: Record<string, boolean> = {},
|
|
||||||
) => ({
|
|
||||||
id: "member-1",
|
|
||||||
role,
|
|
||||||
userId: "user-1",
|
|
||||||
organizationId: "org-1",
|
|
||||||
accessedProjects: [] as string[],
|
|
||||||
accessedServices: [] as string[],
|
|
||||||
accessedEnvironments: [] as string[],
|
|
||||||
canCreateProjects: overrides.canCreateProjects ?? false,
|
|
||||||
canDeleteProjects: overrides.canDeleteProjects ?? false,
|
|
||||||
canCreateServices: overrides.canCreateServices ?? false,
|
|
||||||
canDeleteServices: overrides.canDeleteServices ?? false,
|
|
||||||
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
|
|
||||||
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
|
|
||||||
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
|
|
||||||
canAccessToDocker: overrides.canAccessToDocker ?? false,
|
|
||||||
canAccessToAPI: overrides.canAccessToAPI ?? false,
|
|
||||||
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
|
|
||||||
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
|
|
||||||
user: { id: "user-1", email: "test@test.com" },
|
|
||||||
});
|
|
||||||
|
|
||||||
let memberToReturn: ReturnType<typeof mockMemberData> =
|
|
||||||
mockMemberData("member");
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/db", () => ({
|
|
||||||
db: {
|
|
||||||
query: {
|
|
||||||
member: {
|
|
||||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
|
||||||
findMany: vi.fn(() => Promise.resolve([])),
|
|
||||||
},
|
|
||||||
organizationRole: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
findMany: vi.fn(() => Promise.resolve([])),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
|
||||||
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { checkPermission } = await import("@dokploy/server/services/permission");
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
user: { id: "user-1" },
|
|
||||||
session: { activeOrganizationId: "org-1" },
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("owner and admin bypass enterprise resources", () => {
|
|
||||||
it("owner bypasses deployment.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("owner");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { deployment: ["read"] }),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("admin bypasses backup.create", async () => {
|
|
||||||
memberToReturn = mockMemberData("admin");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { backup: ["create"] }),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("owner bypasses multiple enterprise permissions at once", async () => {
|
|
||||||
memberToReturn = mockMemberData("owner");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, {
|
|
||||||
deployment: ["read"],
|
|
||||||
backup: ["create"],
|
|
||||||
domain: ["delete"],
|
|
||||||
}),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("member is denied org-level enterprise resources (CVE: bypass via staticRoles)", () => {
|
|
||||||
it("member is denied registry.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { registry: ["read"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member is denied certificate.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { certificate: ["read"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member is denied destination.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { destination: ["read"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member is denied notification.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { notification: ["read"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member is denied auditLog.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { auditLog: ["read"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member is denied server.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member is denied registry.create", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { registry: ["create"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("static roles validate free-tier resources", () => {
|
|
||||||
it("owner passes project.create", async () => {
|
|
||||||
memberToReturn = mockMemberData("owner");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { project: ["create"] }),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member fails project.create (no legacy override)", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { project: ["create"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member passes service.read", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { service: ["read"] }),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member fails service.create", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { service: ["create"] }),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("legacy boolean overrides for member", () => {
|
|
||||||
it("member passes project.create with canCreateProjects=true", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", { canCreateProjects: true });
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { project: ["create"] }),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member passes docker.read with canAccessToDocker=true", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
|
|
||||||
await expect(
|
|
||||||
checkPermission(ctx, { docker: ["read"] }),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member fails docker.read with canAccessToDocker=false", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
await expect(checkPermission(ctx, { docker: ["read"] })).rejects.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import {
|
|
||||||
enterpriseOnlyResources,
|
|
||||||
statements,
|
|
||||||
} from "@dokploy/server/lib/access-control";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
const FREE_TIER_RESOURCES = [
|
|
||||||
"organization",
|
|
||||||
"member",
|
|
||||||
"invitation",
|
|
||||||
"team",
|
|
||||||
"ac",
|
|
||||||
"project",
|
|
||||||
"service",
|
|
||||||
"environment",
|
|
||||||
"docker",
|
|
||||||
"sshKeys",
|
|
||||||
"gitProviders",
|
|
||||||
"traefikFiles",
|
|
||||||
"api",
|
|
||||||
];
|
|
||||||
|
|
||||||
const ENTERPRISE_RESOURCES = [
|
|
||||||
"volume",
|
|
||||||
"deployment",
|
|
||||||
"envVars",
|
|
||||||
"projectEnvVars",
|
|
||||||
"environmentEnvVars",
|
|
||||||
"server",
|
|
||||||
"registry",
|
|
||||||
"certificate",
|
|
||||||
"backup",
|
|
||||||
"volumeBackup",
|
|
||||||
"schedule",
|
|
||||||
"domain",
|
|
||||||
"destination",
|
|
||||||
"notification",
|
|
||||||
"tag",
|
|
||||||
"logs",
|
|
||||||
"monitoring",
|
|
||||||
"auditLog",
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("enterpriseOnlyResources set", () => {
|
|
||||||
it("contains all enterprise resources", () => {
|
|
||||||
for (const resource of ENTERPRISE_RESOURCES) {
|
|
||||||
expect(enterpriseOnlyResources.has(resource)).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT contain free-tier resources", () => {
|
|
||||||
for (const resource of FREE_TIER_RESOURCES) {
|
|
||||||
expect(enterpriseOnlyResources.has(resource)).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("every resource in statements is either free or enterprise", () => {
|
|
||||||
const allResources = Object.keys(statements);
|
|
||||||
for (const resource of allResources) {
|
|
||||||
const isFree = FREE_TIER_RESOURCES.includes(resource);
|
|
||||||
const isEnterprise = enterpriseOnlyResources.has(resource);
|
|
||||||
expect(isFree || isEnterprise).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("free and enterprise sets don't overlap", () => {
|
|
||||||
for (const resource of FREE_TIER_RESOURCES) {
|
|
||||||
expect(enterpriseOnlyResources.has(resource)).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("all statement resources are accounted for", () => {
|
|
||||||
const allResources = Object.keys(statements);
|
|
||||||
const categorized = [...FREE_TIER_RESOURCES, ...ENTERPRISE_RESOURCES];
|
|
||||||
for (const resource of allResources) {
|
|
||||||
expect(categorized).toContain(resource);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const mockMemberData = (
|
|
||||||
role: string,
|
|
||||||
overrides: Record<string, boolean> = {},
|
|
||||||
) => ({
|
|
||||||
id: "member-1",
|
|
||||||
role,
|
|
||||||
userId: "user-1",
|
|
||||||
organizationId: "org-1",
|
|
||||||
accessedProjects: [] as string[],
|
|
||||||
accessedServices: [] as string[],
|
|
||||||
accessedEnvironments: [] as string[],
|
|
||||||
canCreateProjects: overrides.canCreateProjects ?? false,
|
|
||||||
canDeleteProjects: overrides.canDeleteProjects ?? false,
|
|
||||||
canCreateServices: overrides.canCreateServices ?? false,
|
|
||||||
canDeleteServices: overrides.canDeleteServices ?? false,
|
|
||||||
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
|
|
||||||
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
|
|
||||||
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
|
|
||||||
canAccessToDocker: overrides.canAccessToDocker ?? false,
|
|
||||||
canAccessToAPI: overrides.canAccessToAPI ?? false,
|
|
||||||
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
|
|
||||||
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
|
|
||||||
user: { id: "user-1", email: "test@test.com" },
|
|
||||||
});
|
|
||||||
|
|
||||||
let memberToReturn: ReturnType<typeof mockMemberData> =
|
|
||||||
mockMemberData("member");
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/db", () => ({
|
|
||||||
db: {
|
|
||||||
query: {
|
|
||||||
member: {
|
|
||||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
|
||||||
findMany: vi.fn(() => Promise.resolve([])),
|
|
||||||
},
|
|
||||||
organizationRole: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
findMany: vi.fn(() => Promise.resolve([])),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
|
||||||
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { resolvePermissions } = await import(
|
|
||||||
"@dokploy/server/services/permission"
|
|
||||||
);
|
|
||||||
const { enterpriseOnlyResources, statements } = await import(
|
|
||||||
"@dokploy/server/lib/access-control"
|
|
||||||
);
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
user: { id: "user-1" },
|
|
||||||
session: { activeOrganizationId: "org-1" },
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("enterprise resources for static roles", () => {
|
|
||||||
it("owner gets true for all enterprise resources", async () => {
|
|
||||||
memberToReturn = mockMemberData("owner");
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
|
|
||||||
for (const resource of enterpriseOnlyResources) {
|
|
||||||
const actions = statements[resource as keyof typeof statements];
|
|
||||||
for (const action of actions) {
|
|
||||||
expect((perms as any)[resource][action]).toBe(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("admin gets true for all enterprise resources", async () => {
|
|
||||||
memberToReturn = mockMemberData("admin");
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
|
|
||||||
for (const resource of enterpriseOnlyResources) {
|
|
||||||
const actions = statements[resource as keyof typeof statements];
|
|
||||||
for (const action of actions) {
|
|
||||||
expect((perms as any)[resource][action]).toBe(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member gets true for service-level enterprise resources", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
|
|
||||||
expect(perms.deployment.read).toBe(true);
|
|
||||||
expect(perms.deployment.create).toBe(true);
|
|
||||||
expect(perms.domain.read).toBe(true);
|
|
||||||
expect(perms.backup.read).toBe(true);
|
|
||||||
expect(perms.logs.read).toBe(true);
|
|
||||||
expect(perms.monitoring.read).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member gets false for org-level enterprise resources", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
|
|
||||||
expect(perms.server.read).toBe(false);
|
|
||||||
expect(perms.registry.read).toBe(false);
|
|
||||||
expect(perms.certificate.read).toBe(false);
|
|
||||||
expect(perms.destination.read).toBe(false);
|
|
||||||
expect(perms.notification.read).toBe(false);
|
|
||||||
expect(perms.auditLog.read).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("free-tier resources for member", () => {
|
|
||||||
it("member gets service.read=true", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
expect(perms.service.read).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member gets project.create=false without legacy override", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
expect(perms.project.create).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member gets project.create=true with canCreateProjects", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", { canCreateProjects: true });
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
expect(perms.project.create).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member gets docker.read=false without legacy override", async () => {
|
|
||||||
memberToReturn = mockMemberData("member");
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
expect(perms.docker.read).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member gets docker.read=true with canAccessToDocker", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
expect(perms.docker.read).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("free-tier resources for owner", () => {
|
|
||||||
it("owner gets all free-tier permissions as true", async () => {
|
|
||||||
memberToReturn = mockMemberData("owner");
|
|
||||||
const perms = await resolvePermissions(ctx);
|
|
||||||
expect(perms.project.create).toBe(true);
|
|
||||||
expect(perms.project.delete).toBe(true);
|
|
||||||
expect(perms.service.create).toBe(true);
|
|
||||||
expect(perms.service.read).toBe(true);
|
|
||||||
expect(perms.service.delete).toBe(true);
|
|
||||||
expect(perms.docker.read).toBe(true);
|
|
||||||
expect(perms.traefikFiles.read).toBe(true);
|
|
||||||
expect(perms.traefikFiles.write).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const mockMemberData = (
|
|
||||||
role: string,
|
|
||||||
accessedServices: string[] = [],
|
|
||||||
accessedProjects: string[] = [],
|
|
||||||
) => ({
|
|
||||||
id: "member-1",
|
|
||||||
role,
|
|
||||||
userId: "user-1",
|
|
||||||
organizationId: "org-1",
|
|
||||||
accessedProjects,
|
|
||||||
accessedServices,
|
|
||||||
accessedEnvironments: [] as string[],
|
|
||||||
canCreateProjects: false,
|
|
||||||
canDeleteProjects: false,
|
|
||||||
canCreateServices: false,
|
|
||||||
canDeleteServices: false,
|
|
||||||
canCreateEnvironments: false,
|
|
||||||
canDeleteEnvironments: false,
|
|
||||||
canAccessToTraefikFiles: false,
|
|
||||||
canAccessToDocker: false,
|
|
||||||
canAccessToAPI: false,
|
|
||||||
canAccessToSSHKeys: false,
|
|
||||||
canAccessToGitProviders: false,
|
|
||||||
user: { id: "user-1", email: "test@test.com" },
|
|
||||||
});
|
|
||||||
|
|
||||||
let memberToReturn: ReturnType<typeof mockMemberData> =
|
|
||||||
mockMemberData("member");
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/db", () => ({
|
|
||||||
db: {
|
|
||||||
query: {
|
|
||||||
member: {
|
|
||||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
|
||||||
findMany: vi.fn(() => Promise.resolve([])),
|
|
||||||
},
|
|
||||||
organizationRole: {
|
|
||||||
findFirst: vi.fn(),
|
|
||||||
findMany: vi.fn(() => Promise.resolve([])),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
|
||||||
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { checkServicePermissionAndAccess, checkServiceAccess } = await import(
|
|
||||||
"@dokploy/server/services/permission"
|
|
||||||
);
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
user: { id: "user-1" },
|
|
||||||
session: { activeOrganizationId: "org-1" },
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("checkServicePermissionAndAccess", () => {
|
|
||||||
it("owner bypasses accessedServices check", async () => {
|
|
||||||
memberToReturn = mockMemberData("owner", []);
|
|
||||||
await expect(
|
|
||||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
|
||||||
deployment: ["read"],
|
|
||||||
}),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("admin bypasses accessedServices check", async () => {
|
|
||||||
memberToReturn = mockMemberData("admin", []);
|
|
||||||
await expect(
|
|
||||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
|
||||||
backup: ["create"],
|
|
||||||
}),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member with access to service passes", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", ["service-123"]);
|
|
||||||
await expect(
|
|
||||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
|
||||||
deployment: ["read"],
|
|
||||||
}),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member WITHOUT access to service fails", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", ["other-service"]);
|
|
||||||
await expect(
|
|
||||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
|
||||||
deployment: ["read"],
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("You don't have access to this service");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member with empty accessedServices fails", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", []);
|
|
||||||
await expect(
|
|
||||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
|
||||||
domain: ["delete"],
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("You don't have access to this service");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("checkServiceAccess", () => {
|
|
||||||
it("member with service access passes read check", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", ["app-1"]);
|
|
||||||
await expect(
|
|
||||||
checkServiceAccess(ctx, "app-1", "read"),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("member without service access fails read check", async () => {
|
|
||||||
memberToReturn = mockMemberData("member", []);
|
|
||||||
await expect(checkServiceAccess(ctx, "app-1", "read")).rejects.toThrow(
|
|
||||||
"You don't have access to this service",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("owner bypasses all access checks", async () => {
|
|
||||||
memberToReturn = mockMemberData("owner", [], []);
|
|
||||||
await expect(
|
|
||||||
checkServiceAccess(ctx, "project-1", "create"),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -57,7 +57,7 @@ const createApplication = (
|
|||||||
env: null,
|
env: null,
|
||||||
},
|
},
|
||||||
replicas: 1,
|
replicas: 1,
|
||||||
stopGracePeriodSwarm: 0,
|
stopGracePeriodSwarm: 0n,
|
||||||
ulimitsSwarm: null,
|
ulimitsSwarm: null,
|
||||||
serverId: "server-id",
|
serverId: "server-id",
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -76,8 +76,8 @@ describe("mechanizeDockerContainer", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes stopGracePeriodSwarm as a number and keeps zero values", async () => {
|
it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => {
|
||||||
const application = createApplication({ stopGracePeriodSwarm: 0 });
|
const application = createApplication({ stopGracePeriodSwarm: 0n });
|
||||||
|
|
||||||
await mechanizeDockerContainer(application);
|
await mechanizeDockerContainer(application);
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,7 @@ vi.mock("@dokploy/server/db", () => {
|
|||||||
chain.where = () => chain;
|
chain.where = () => chain;
|
||||||
chain.values = () => chain;
|
chain.values = () => chain;
|
||||||
chain.returning = () => Promise.resolve([{}]);
|
chain.returning = () => Promise.resolve([{}]);
|
||||||
chain.from = () => chain;
|
chain.then = undefined;
|
||||||
chain.innerJoin = () => chain;
|
|
||||||
chain.then = (resolve: (value: unknown) => void) => {
|
|
||||||
resolve([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const tableMock = {
|
const tableMock = {
|
||||||
findFirst: vi.fn(() => Promise.resolve(undefined)),
|
findFirst: vi.fn(() => Promise.resolve(undefined)),
|
||||||
@@ -25,6 +21,7 @@ vi.mock("@dokploy/server/db", () => {
|
|||||||
update: vi.fn(() => chain),
|
update: vi.fn(() => chain),
|
||||||
delete: vi.fn(() => chain),
|
delete: vi.fn(() => chain),
|
||||||
};
|
};
|
||||||
|
const createQueryMock = () => tableMock;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db: {
|
db: {
|
||||||
|
|||||||
@@ -494,49 +494,4 @@ describe("processTemplate", () => {
|
|||||||
expect(result.mounts).toHaveLength(1);
|
expect(result.mounts).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isolated deployment config", () => {
|
|
||||||
it("should default to isolated=true when not specified", () => {
|
|
||||||
const template: CompleteTemplate = {
|
|
||||||
metadata: {} as any,
|
|
||||||
variables: {},
|
|
||||||
config: {
|
|
||||||
domains: [],
|
|
||||||
env: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(template.config.isolated).toBeUndefined();
|
|
||||||
// undefined !== false => isolatedDeployment = true
|
|
||||||
expect(template.config.isolated !== false).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should be isolated when isolated=true is explicitly set", () => {
|
|
||||||
const template: CompleteTemplate = {
|
|
||||||
metadata: {} as any,
|
|
||||||
variables: {},
|
|
||||||
config: {
|
|
||||||
isolated: true,
|
|
||||||
domains: [],
|
|
||||||
env: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(template.config.isolated !== false).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should disable isolated deployment when isolated=false", () => {
|
|
||||||
const template: CompleteTemplate = {
|
|
||||||
metadata: {} as any,
|
|
||||||
variables: {},
|
|
||||||
config: {
|
|
||||||
isolated: false,
|
|
||||||
domains: [],
|
|
||||||
env: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(template.config.isolated !== false).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ describe("helpers functions", () => {
|
|||||||
const domain = processValue("${domain}", {}, mockSchema);
|
const domain = processValue("${domain}", {}, mockSchema);
|
||||||
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
|
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
domain.endsWith(`${mockSchema.serverIp.replaceAll(".", "-")}.sslip.io`),
|
domain.endsWith(
|
||||||
|
`${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
|
||||||
|
),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,233 +0,0 @@
|
|||||||
import type { ApplicationNested, Domain } from "@dokploy/server";
|
|
||||||
import {
|
|
||||||
buildForwardAuthEnv,
|
|
||||||
createRouterConfig,
|
|
||||||
deriveBaseDomain,
|
|
||||||
deriveCookieSecret,
|
|
||||||
forwardAuthCallbackUrl,
|
|
||||||
forwardAuthMiddlewareName,
|
|
||||||
} from "@dokploy/server";
|
|
||||||
import { beforeAll, describe, expect, test } from "vitest";
|
|
||||||
|
|
||||||
const app = {
|
|
||||||
appName: "my-app",
|
|
||||||
redirects: [],
|
|
||||||
security: [],
|
|
||||||
} as unknown as ApplicationNested;
|
|
||||||
|
|
||||||
const baseDomain: Domain = {
|
|
||||||
applicationId: "app-1",
|
|
||||||
certificateType: "none",
|
|
||||||
createdAt: "",
|
|
||||||
domainId: "domain-1",
|
|
||||||
host: "app.example.com",
|
|
||||||
https: false,
|
|
||||||
path: null,
|
|
||||||
port: 3000,
|
|
||||||
customEntrypoint: null,
|
|
||||||
serviceName: "",
|
|
||||||
composeId: "",
|
|
||||||
customCertResolver: null,
|
|
||||||
domainType: "application",
|
|
||||||
uniqueConfigKey: 7,
|
|
||||||
previewDeploymentId: "",
|
|
||||||
internalPath: "/",
|
|
||||||
stripPath: false,
|
|
||||||
middlewares: null,
|
|
||||||
forwardAuthEnabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("forwardAuthMiddlewareName", () => {
|
|
||||||
test("is stable and unique per app + uniqueConfigKey", () => {
|
|
||||||
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
|
|
||||||
"forward-auth-my-app-7",
|
|
||||||
);
|
|
||||||
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
|
|
||||||
forwardAuthMiddlewareName("my-app", 7),
|
|
||||||
);
|
|
||||||
expect(forwardAuthMiddlewareName("my-app", 7)).not.toBe(
|
|
||||||
forwardAuthMiddlewareName("my-app", 8),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createRouterConfig forward-auth wiring", () => {
|
|
||||||
test("does NOT add forward-auth middleware when no provider is linked", async () => {
|
|
||||||
const config = await createRouterConfig(app, baseDomain, "websecure");
|
|
||||||
expect(config.middlewares).not.toContain(
|
|
||||||
forwardAuthMiddlewareName("my-app", 7),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("adds forward-auth middleware when a provider is linked", async () => {
|
|
||||||
const domain: Domain = {
|
|
||||||
...baseDomain,
|
|
||||||
forwardAuthEnabled: true,
|
|
||||||
};
|
|
||||||
const config = await createRouterConfig(app, domain, "websecure");
|
|
||||||
expect(config.middlewares).toContain(
|
|
||||||
forwardAuthMiddlewareName("my-app", 7),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("forward-auth runs before custom domain middlewares", async () => {
|
|
||||||
const domain: Domain = {
|
|
||||||
...baseDomain,
|
|
||||||
forwardAuthEnabled: true,
|
|
||||||
middlewares: ["rate-limit@file"],
|
|
||||||
};
|
|
||||||
const config = await createRouterConfig(app, domain, "websecure");
|
|
||||||
const forwardAuthIdx = config.middlewares?.indexOf(
|
|
||||||
forwardAuthMiddlewareName("my-app", 7),
|
|
||||||
);
|
|
||||||
const customIdx = config.middlewares?.indexOf("rate-limit@file");
|
|
||||||
expect(forwardAuthIdx).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(customIdx).toBeGreaterThan(forwardAuthIdx as number);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("redirect-only web router does not get the forward-auth middleware", async () => {
|
|
||||||
const domain: Domain = {
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
forwardAuthEnabled: true,
|
|
||||||
};
|
|
||||||
const config = await createRouterConfig(app, domain, "web");
|
|
||||||
expect(config.middlewares).toContain("redirect-to-https");
|
|
||||||
expect(config.middlewares).not.toContain(
|
|
||||||
forwardAuthMiddlewareName("my-app", 7),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildForwardAuthEnv", () => {
|
|
||||||
const baseOptions = {
|
|
||||||
oidc: {
|
|
||||||
clientId: "client-123",
|
|
||||||
clientSecret: "secret-xyz",
|
|
||||||
issuer: "https://idp.example.com",
|
|
||||||
},
|
|
||||||
cookieSecret: "cookie-secret-value",
|
|
||||||
authDomain: "auth.acme.com",
|
|
||||||
baseDomain: ".acme.com",
|
|
||||||
authDomainHttps: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
test("emits the required oauth2-proxy OIDC env vars", () => {
|
|
||||||
const env = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_PROVIDER=oidc");
|
|
||||||
expect(env).toContain(
|
|
||||||
"OAUTH2_PROXY_OIDC_ISSUER_URL=https://idp.example.com",
|
|
||||||
);
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_CLIENT_ID=client-123");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_CLIENT_SECRET=secret-xyz");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_COOKIE_SECRET=cookie-secret-value");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_REVERSE_PROXY=true");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("uses the central auth domain for the single fixed callback", () => {
|
|
||||||
const env = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(env).toContain(
|
|
||||||
"OAUTH2_PROXY_REDIRECT_URL=https://auth.acme.com/oauth2/callback",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shares cookie + whitelist on the base domain (no per-app redeploy)", () => {
|
|
||||||
const env = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_COOKIE_DOMAINS=.acme.com");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_WHITELIST_DOMAINS=.acme.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matches cookie Secure flag and callback scheme to https setting", () => {
|
|
||||||
const https = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(https).toContain("OAUTH2_PROXY_COOKIE_SECURE=true");
|
|
||||||
|
|
||||||
const http = buildForwardAuthEnv({
|
|
||||||
...baseOptions,
|
|
||||||
authDomainHttps: false,
|
|
||||||
});
|
|
||||||
expect(http).toContain("OAUTH2_PROXY_COOKIE_SECURE=false");
|
|
||||||
expect(http).toContain(
|
|
||||||
"OAUTH2_PROXY_REDIRECT_URL=http://auth.acme.com/oauth2/callback",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("allows unverified emails so OIDC providers don't 500 the callback", () => {
|
|
||||||
const env = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(env).toContain(
|
|
||||||
"OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("defaults to any authenticated user and standard scopes", () => {
|
|
||||||
const env = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=*");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid email profile");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("honors custom scopes and email domains", () => {
|
|
||||||
const env = buildForwardAuthEnv({
|
|
||||||
...baseOptions,
|
|
||||||
oidc: { ...baseOptions.oidc, scopes: ["openid", "groups"] },
|
|
||||||
emailDomains: ["acme.com", "corp.com"],
|
|
||||||
});
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid groups");
|
|
||||||
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=acme.com,corp.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sets skip-discovery flag only when requested", () => {
|
|
||||||
const withoutSkip = buildForwardAuthEnv(baseOptions);
|
|
||||||
expect(withoutSkip).not.toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
|
|
||||||
|
|
||||||
const withSkip = buildForwardAuthEnv({
|
|
||||||
...baseOptions,
|
|
||||||
oidc: { ...baseOptions.oidc, skipDiscovery: true },
|
|
||||||
});
|
|
||||||
expect(withSkip).toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("deriveBaseDomain", () => {
|
|
||||||
test("strips the auth subdomain to the shared base", () => {
|
|
||||||
expect(deriveBaseDomain("auth.acme.com")).toBe(".acme.com");
|
|
||||||
expect(deriveBaseDomain("sso.apps.acme.com")).toBe(".apps.acme.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps a two-label apex as the base", () => {
|
|
||||||
expect(deriveBaseDomain("acme.com")).toBe(".acme.com");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("forwardAuthCallbackUrl", () => {
|
|
||||||
test("builds the single IdP callback per scheme", () => {
|
|
||||||
expect(forwardAuthCallbackUrl("auth.acme.com", true)).toBe(
|
|
||||||
"https://auth.acme.com/oauth2/callback",
|
|
||||||
);
|
|
||||||
expect(forwardAuthCallbackUrl("auth.acme.com", false)).toBe(
|
|
||||||
"http://auth.acme.com/oauth2/callback",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("deriveCookieSecret", () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
process.env.BETTER_AUTH_SECRET = "test-root-secret";
|
|
||||||
});
|
|
||||||
|
|
||||||
test("is deterministic for the same salt (survives service updates)", () => {
|
|
||||||
expect(deriveCookieSecret(".acme.com")).toBe(
|
|
||||||
deriveCookieSecret(".acme.com"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("differs per salt", () => {
|
|
||||||
expect(deriveCookieSecret(".acme.com")).not.toBe(
|
|
||||||
deriveCookieSecret(".other.com"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("produces a 16-byte hex secret (oauth2-proxy requirement)", () => {
|
|
||||||
const secret = deriveCookieSecret(".acme.com");
|
|
||||||
expect(Buffer.from(secret, "hex")).toHaveLength(16);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -48,25 +48,9 @@ const baseSettings: WebServerSettings = {
|
|||||||
urlCallback: "",
|
urlCallback: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
whitelabelingConfig: {
|
|
||||||
appName: null,
|
|
||||||
appDescription: null,
|
|
||||||
logoUrl: null,
|
|
||||||
faviconUrl: null,
|
|
||||||
customCss: null,
|
|
||||||
loginLogoUrl: null,
|
|
||||||
supportUrl: null,
|
|
||||||
docsUrl: null,
|
|
||||||
errorPageTitle: null,
|
|
||||||
errorPageDescription: null,
|
|
||||||
metaTitle: null,
|
|
||||||
footerText: null,
|
|
||||||
},
|
|
||||||
cleanupCacheApplications: false,
|
cleanupCacheApplications: false,
|
||||||
cleanupCacheOnCompose: false,
|
cleanupCacheOnCompose: false,
|
||||||
cleanupCacheOnPreviews: false,
|
cleanupCacheOnPreviews: false,
|
||||||
remoteServersOnly: false,
|
|
||||||
enforceSSO: false,
|
|
||||||
createdAt: null,
|
createdAt: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ const baseApp: ApplicationNested = {
|
|||||||
dropBuildPath: null,
|
dropBuildPath: null,
|
||||||
enabled: null,
|
enabled: null,
|
||||||
env: null,
|
env: null,
|
||||||
icon: null,
|
|
||||||
healthCheckSwarm: null,
|
healthCheckSwarm: null,
|
||||||
labelsSwarm: null,
|
labelsSwarm: null,
|
||||||
memoryLimit: null,
|
memoryLimit: null,
|
||||||
@@ -138,7 +137,6 @@ const baseDomain: Domain = {
|
|||||||
https: false,
|
https: false,
|
||||||
path: null,
|
path: null,
|
||||||
port: null,
|
port: null,
|
||||||
customEntrypoint: null,
|
|
||||||
serviceName: "",
|
serviceName: "",
|
||||||
composeId: "",
|
composeId: "",
|
||||||
customCertResolver: null,
|
customCertResolver: null,
|
||||||
@@ -147,8 +145,6 @@ const baseDomain: Domain = {
|
|||||||
previewDeploymentId: "",
|
previewDeploymentId: "",
|
||||||
internalPath: "/",
|
internalPath: "/",
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
middlewares: null,
|
|
||||||
forwardAuthEnabled: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseRedirect: Redirect = {
|
const baseRedirect: Redirect = {
|
||||||
@@ -268,80 +264,6 @@ test("Websecure entrypoint on https domain with redirect", async () => {
|
|||||||
expect(router.middlewares).toContain("redirect-test-1");
|
expect(router.middlewares).toContain("redirect-test-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Custom Middlewares */
|
|
||||||
|
|
||||||
test("Web entrypoint with single custom middleware", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, middlewares: ["auth@file"] },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.middlewares).toContain("auth@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Web entrypoint with multiple custom middlewares", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, middlewares: ["auth@file", "rate-limit@file"] },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.middlewares).toContain("auth@file");
|
|
||||||
expect(router.middlewares).toContain("rate-limit@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Web entrypoint on https domain with custom middleware", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should only have HTTPS redirect - custom middleware applies on websecure
|
|
||||||
expect(router.middlewares).toContain("redirect-to-https");
|
|
||||||
expect(router.middlewares).not.toContain("auth@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Websecure entrypoint with custom middleware", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
|
|
||||||
"websecure",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should have custom middleware but not HTTPS redirect
|
|
||||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
|
||||||
expect(router.middlewares).toContain("auth@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Web entrypoint with redirect and custom middleware", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
{
|
|
||||||
...baseApp,
|
|
||||||
appName: "test",
|
|
||||||
redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }],
|
|
||||||
},
|
|
||||||
{ ...baseDomain, middlewares: ["auth@file"] },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should have both redirect middleware and custom middleware
|
|
||||||
expect(router.middlewares).toContain("redirect-test-1");
|
|
||||||
expect(router.middlewares).toContain("auth@file");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Web entrypoint with empty middlewares array", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, https: false, middlewares: [] },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should behave same as no middlewares - no redirect for http
|
|
||||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Certificates */
|
/** Certificates */
|
||||||
|
|
||||||
test("CertificateType on websecure entrypoint", async () => {
|
test("CertificateType on websecure entrypoint", async () => {
|
||||||
@@ -353,175 +275,3 @@ test("CertificateType on websecure entrypoint", async () => {
|
|||||||
|
|
||||||
expect(router.tls?.certResolver).toBe("letsencrypt");
|
expect(router.tls?.certResolver).toBe("letsencrypt");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Custom entrypoint on http domain", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, https: false, customEntrypoint: "custom" },
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
|
||||||
expect(router.tls).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Custom entrypoint on https domain", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
certificateType: "letsencrypt",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
expect(router.middlewares).not.toContain("redirect-to-https");
|
|
||||||
expect(router.tls?.certResolver).toBe("letsencrypt");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Custom entrypoint with path includes PathPrefix in rule", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, customEntrypoint: "custom", path: "/api" },
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.rule).toContain("PathPrefix(`/api`)");
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Custom entrypoint with stripPath adds stripprefix middleware", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
path: "/api",
|
|
||||||
stripPath: true,
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.middlewares).toContain("stripprefix--1");
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Custom entrypoint with internalPath adds addprefix middleware", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
internalPath: "/hello",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.middlewares).toContain("addprefix--1");
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("stripPath and internalPath together: stripprefix must come before addprefix", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
path: "/public",
|
|
||||||
stripPath: true,
|
|
||||||
internalPath: "/app/v2",
|
|
||||||
},
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
const stripIndex = router.middlewares?.indexOf("stripprefix--1") ?? -1;
|
|
||||||
const addIndex = router.middlewares?.indexOf("addprefix--1") ?? -1;
|
|
||||||
|
|
||||||
expect(stripIndex).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(addIndex).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(stripIndex).toBeLessThan(addIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Custom entrypoint with https and custom cert resolver", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
https: true,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
certificateType: "custom",
|
|
||||||
customCertResolver: "myresolver",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
expect(router.tls?.certResolver).toBe("myresolver");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Custom entrypoint without https should not have tls", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{
|
|
||||||
...baseDomain,
|
|
||||||
https: false,
|
|
||||||
customEntrypoint: "custom",
|
|
||||||
certificateType: "letsencrypt",
|
|
||||||
},
|
|
||||||
"custom",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.entryPoints).toEqual(["custom"]);
|
|
||||||
expect(router.tls).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
/** IDN/Punycode */
|
|
||||||
|
|
||||||
test("Internationalized domain name is converted to punycode", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, host: "тест.рф" },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
// тест.рф in punycode is xn--e1aybc.xn--p1ai
|
|
||||||
expect(router.rule).toContain("Host(`xn--e1aybc.xn--p1ai`)");
|
|
||||||
expect(router.rule).not.toContain("тест.рф");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ASCII domain remains unchanged", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, host: "example.com" },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(router.rule).toContain("Host(`example.com`)");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Russian Cyrillic label with .ru TLD is converted to punycode", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, host: "сайт.ru" },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
// сайт in punycode is xn--80aswg
|
|
||||||
expect(router.rule).toContain("Host(`xn--80aswg.ru`)");
|
|
||||||
expect(router.rule).not.toContain("сайт");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Subdomain with Russian IDN TLD converts non-ASCII part to punycode", async () => {
|
|
||||||
const router = await createRouterConfig(
|
|
||||||
baseApp,
|
|
||||||
{ ...baseDomain, host: "app.тест.рф" },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
|
|
||||||
// app stays ASCII, тест.рф becomes xn--e1aybc.xn--p1ai
|
|
||||||
expect(router.rule).toContain("Host(`app.xn--e1aybc.xn--p1ai`)");
|
|
||||||
expect(router.rule).not.toContain("тест.рф");
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const BASE = "/base";
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
|
||||||
const actual =
|
|
||||||
await importOriginal<typeof import("@dokploy/server/constants")>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
paths: () => ({
|
|
||||||
...actual.paths(),
|
|
||||||
BASE_PATH: BASE,
|
|
||||||
LOGS_PATH: `${BASE}/logs`,
|
|
||||||
APPLICATIONS_PATH: `${BASE}/applications`,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Import after mock so paths() uses our BASE
|
|
||||||
const { readValidDirectory } = await import("@dokploy/server");
|
|
||||||
|
|
||||||
describe("readValidDirectory (path traversal)", () => {
|
|
||||||
it("returns true when directory is exactly BASE_PATH", () => {
|
|
||||||
expect(readValidDirectory(BASE)).toBe(true);
|
|
||||||
expect(readValidDirectory(path.resolve(BASE))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true when directory is under BASE_PATH", () => {
|
|
||||||
expect(readValidDirectory(`${BASE}/logs`)).toBe(true);
|
|
||||||
expect(readValidDirectory(`${BASE}/logs/app/foo.log`)).toBe(true);
|
|
||||||
expect(readValidDirectory(`${BASE}/applications/myapp/code`)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for path traversal escaping base (absolute)", () => {
|
|
||||||
expect(readValidDirectory("/etc/passwd")).toBe(false);
|
|
||||||
expect(readValidDirectory("/etc/cron.d/malicious")).toBe(false);
|
|
||||||
expect(readValidDirectory("/tmp/outside")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when resolved path escapes base via ..", () => {
|
|
||||||
// Resolved: /etc/passwd (outside /base)
|
|
||||||
expect(readValidDirectory(`${BASE}/../etc/passwd`)).toBe(false);
|
|
||||||
expect(readValidDirectory(`${BASE}/logs/../../etc/passwd`)).toBe(false);
|
|
||||||
expect(readValidDirectory(`${BASE}/..`)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true when .. stays within base", () => {
|
|
||||||
// e.g. /base/logs/../applications -> /base/applications (still under /base)
|
|
||||||
expect(readValidDirectory(`${BASE}/logs/../applications`)).toBe(true);
|
|
||||||
expect(readValidDirectory(`${BASE}/foo/../bar`)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts serverId for remote base path", () => {
|
|
||||||
// With our mock, serverId doesn't change BASE_PATH; just ensure it doesn't throw
|
|
||||||
expect(readValidDirectory(BASE, "server-1")).toBe(true);
|
|
||||||
expect(readValidDirectory("/etc/passwd", "server-1")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for null/undefined-like paths that resolve outside", () => {
|
|
||||||
// Paths that might resolve to cwd or root
|
|
||||||
expect(readValidDirectory(".")).toBe(false);
|
|
||||||
expect(readValidDirectory("..")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true for BASE_PATH with trailing slash or double slashes under base", () => {
|
|
||||||
expect(readValidDirectory(`${BASE}/`)).toBe(true);
|
|
||||||
expect(readValidDirectory(`${BASE}//logs`)).toBe(true);
|
|
||||||
expect(readValidDirectory(`${BASE}/applications///myapp/code`)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when path looks like base but is a sibling or prefix", () => {
|
|
||||||
expect(readValidDirectory("/base-evil")).toBe(false);
|
|
||||||
expect(readValidDirectory("/bas")).toBe(false);
|
|
||||||
expect(readValidDirectory(`${BASE}/../base-evil`)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false for empty string (resolves to cwd)", () => {
|
|
||||||
expect(readValidDirectory("")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true for Next.js dynamic route paths with square brackets", () => {
|
|
||||||
expect(
|
|
||||||
readValidDirectory(
|
|
||||||
`${BASE}/applications/myapp/code/app/api/[id]/route.ts`,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
readValidDirectory(`${BASE}/applications/myapp/code/pages/[slug].tsx`),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
readValidDirectory(
|
|
||||||
`${BASE}/applications/myapp/code/app/[...catch]/page.tsx`,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
isValidContainerId,
|
|
||||||
isValidSearch,
|
|
||||||
isValidSince,
|
|
||||||
isValidTail,
|
|
||||||
} from "../../server/wss/utils";
|
|
||||||
|
|
||||||
describe("isValidTail (docker-container-logs)", () => {
|
|
||||||
it("accepts valid numeric tail values", () => {
|
|
||||||
expect(isValidTail("0")).toBe(true);
|
|
||||||
expect(isValidTail("1")).toBe(true);
|
|
||||||
expect(isValidTail("100")).toBe(true);
|
|
||||||
expect(isValidTail("10000")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects tail above 10000", () => {
|
|
||||||
expect(isValidTail("10001")).toBe(false);
|
|
||||||
expect(isValidTail("99999")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects non-numeric tail", () => {
|
|
||||||
expect(isValidTail("")).toBe(false);
|
|
||||||
expect(isValidTail("abc")).toBe(false);
|
|
||||||
expect(isValidTail("10a")).toBe(false);
|
|
||||||
expect(isValidTail("-1")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects command injection payloads in tail", () => {
|
|
||||||
expect(isValidTail("10; whoami; #")).toBe(false);
|
|
||||||
expect(isValidTail("100 | cat /etc/passwd")).toBe(false);
|
|
||||||
expect(isValidTail("$(id)")).toBe(false);
|
|
||||||
expect(isValidTail("`id`")).toBe(false);
|
|
||||||
expect(isValidTail("100\nid")).toBe(false);
|
|
||||||
expect(isValidTail("100 && id")).toBe(false);
|
|
||||||
expect(isValidTail("100; env | grep DATABASE")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isValidSince (docker-container-logs)", () => {
|
|
||||||
it("accepts 'all'", () => {
|
|
||||||
expect(isValidSince("all")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts valid duration format (number + s|m|h|d)", () => {
|
|
||||||
expect(isValidSince("5s")).toBe(true);
|
|
||||||
expect(isValidSince("10m")).toBe(true);
|
|
||||||
expect(isValidSince("1h")).toBe(true);
|
|
||||||
expect(isValidSince("2d")).toBe(true);
|
|
||||||
expect(isValidSince("0s")).toBe(true);
|
|
||||||
expect(isValidSince("999d")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects invalid duration format", () => {
|
|
||||||
expect(isValidSince("")).toBe(false);
|
|
||||||
expect(isValidSince("5")).toBe(false);
|
|
||||||
expect(isValidSince("s")).toBe(false);
|
|
||||||
expect(isValidSince("5x")).toBe(false);
|
|
||||||
expect(isValidSince("5sec")).toBe(false);
|
|
||||||
expect(isValidSince("5 m")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects command injection payloads in since", () => {
|
|
||||||
expect(isValidSince("5s; whoami")).toBe(false);
|
|
||||||
expect(isValidSince("all; id")).toBe(false);
|
|
||||||
expect(isValidSince("1m$(id)")).toBe(false);
|
|
||||||
expect(isValidSince("1m | cat /etc/passwd")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isValidSearch (docker-container-logs)", () => {
|
|
||||||
it("accepts empty string", () => {
|
|
||||||
expect(isValidSearch("")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts only alphanumeric, space, dot, underscore, hyphen", () => {
|
|
||||||
expect(isValidSearch("error")).toBe(true);
|
|
||||||
expect(isValidSearch("foo bar")).toBe(true);
|
|
||||||
expect(isValidSearch("a-zA-Z0-9_.-")).toBe(true);
|
|
||||||
expect(isValidSearch("")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects strings longer than 500 chars", () => {
|
|
||||||
expect(isValidSearch("a".repeat(501))).toBe(false);
|
|
||||||
expect(isValidSearch("a".repeat(500))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects control characters and non-printable", () => {
|
|
||||||
expect(isValidSearch("foo\nbar")).toBe(false);
|
|
||||||
expect(isValidSearch("foo\rbar")).toBe(false);
|
|
||||||
expect(isValidSearch("\x00")).toBe(false);
|
|
||||||
expect(isValidSearch("a\x19b")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects command injection vectors in search (search is concatenated into shell)", () => {
|
|
||||||
// Double-quoted context (SSH line 99): $ and ` execute
|
|
||||||
expect(isValidSearch("$(whoami)")).toBe(false);
|
|
||||||
expect(isValidSearch("`id`")).toBe(false);
|
|
||||||
expect(isValidSearch("$(id)")).toBe(false);
|
|
||||||
// Single-quoted context (local line 153): ' breaks out
|
|
||||||
expect(isValidSearch("'$(whoami)'")).toBe(false);
|
|
||||||
expect(isValidSearch("error'")).toBe(false);
|
|
||||||
expect(isValidSearch("'; whoami; #")).toBe(false);
|
|
||||||
// Other shell-metacharacters
|
|
||||||
expect(isValidSearch("error; id")).toBe(false);
|
|
||||||
expect(isValidSearch("a|b")).toBe(false);
|
|
||||||
expect(isValidSearch('error"')).toBe(false);
|
|
||||||
expect(isValidSearch("a&b")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isValidContainerId (docker-container-logs)", () => {
|
|
||||||
it("accepts valid hex container IDs", () => {
|
|
||||||
expect(isValidContainerId("a".repeat(12))).toBe(true);
|
|
||||||
expect(isValidContainerId("abc123def456")).toBe(true);
|
|
||||||
expect(isValidContainerId("a".repeat(64))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts valid container names", () => {
|
|
||||||
expect(isValidContainerId("my-container")).toBe(true);
|
|
||||||
expect(isValidContainerId("app_1")).toBe(true);
|
|
||||||
expect(isValidContainerId("service.name")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects command injection in container ID", () => {
|
|
||||||
expect(isValidContainerId("dummy; whoami")).toBe(false);
|
|
||||||
expect(isValidContainerId("$(id)")).toBe(false);
|
|
||||||
expect(isValidContainerId("`id`")).toBe(false);
|
|
||||||
expect(isValidContainerId("container|cat /etc/passwd")).toBe(false);
|
|
||||||
expect(isValidContainerId("x; env | grep DATABASE")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -112,21 +112,14 @@ const menuItems: MenuItem[] = [
|
|||||||
|
|
||||||
const hasStopGracePeriodSwarm = (
|
const hasStopGracePeriodSwarm = (
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): value is { stopGracePeriodSwarm: number | string | null } =>
|
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
|
||||||
typeof value === "object" &&
|
typeof value === "object" &&
|
||||||
value !== null &&
|
value !== null &&
|
||||||
"stopGracePeriodSwarm" in value;
|
"stopGracePeriodSwarm" in value;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "application"
|
|
||||||
| "libsql"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "postgres"
|
|
||||||
| "redis";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddSwarmSettings = ({ id, type }: Props) => {
|
export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Server } from "lucide-react";
|
import { Server } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -37,27 +37,27 @@ import { AddSwarmSettings } from "./modify-swarm-settings";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
type: "application" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
}
|
}
|
||||||
|
|
||||||
const AddRedirectSchema = z.object({
|
const AddRedirectchema = z.object({
|
||||||
replicas: z.number().min(1, "Replicas must be at least 1"),
|
replicas: z.number().min(1, "Replicas must be at least 1"),
|
||||||
registryId: z.string().optional(),
|
registryId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
type AddCommand = z.infer<typeof AddRedirectchema>;
|
||||||
|
|
||||||
export const ShowClusterSettings = ({ id, type }: Props) => {
|
export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||||
const queryMap = {
|
const queryMap = {
|
||||||
application: () =>
|
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
|
||||||
mariadb: () =>
|
|
||||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
|
||||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
|
||||||
postgres: () =>
|
postgres: () =>
|
||||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -65,16 +65,15 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
|||||||
const { data: registries } = api.registry.all.useQuery();
|
const { data: registries } = api.registry.all.useQuery();
|
||||||
|
|
||||||
const mutationMap = {
|
const mutationMap = {
|
||||||
application: () => api.application.update.useMutation(),
|
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
mariadb: () => api.mariadb.update.useMutation(),
|
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
|
||||||
mysql: () => api.mysql.update.useMutation(),
|
|
||||||
postgres: () => api.postgres.update.useMutation(),
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
redis: () => api.redis.update.useMutation(),
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync, isPending } = mutationMap[type]
|
const { mutateAsync, isLoading } = mutationMap[type]
|
||||||
? mutationMap[type]()
|
? mutationMap[type]()
|
||||||
: api.mongo.update.useMutation();
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
@@ -87,7 +86,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
|||||||
: {}),
|
: {}),
|
||||||
replicas: data?.replicas || 1,
|
replicas: data?.replicas || 1,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(AddRedirectSchema),
|
resolver: zodResolver(AddRedirectchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -106,11 +105,11 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
|||||||
const onSubmit = async (data: AddCommand) => {
|
const onSubmit = async (data: AddCommand) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
applicationId: id || "",
|
applicationId: id || "",
|
||||||
mariadbId: id || "",
|
|
||||||
mongoId: id || "",
|
|
||||||
mysqlId: id || "",
|
|
||||||
postgresId: id || "",
|
postgresId: id || "",
|
||||||
redisId: id || "",
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
...(type === "application"
|
...(type === "application"
|
||||||
? {
|
? {
|
||||||
registryId:
|
registryId:
|
||||||
@@ -237,7 +236,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button isLoading={isPending} type="submit" className="w-fit">
|
<Button isLoading={isLoading} type="submit" className="w-fit">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -28,14 +28,7 @@ export const endpointSpecFormSchema = z.object({
|
|||||||
|
|
||||||
interface EndpointSpecFormProps {
|
interface EndpointSpecFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
||||||
@@ -51,7 +44,6 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -64,7 +56,6 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -103,7 +94,6 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
endpointSpecSwarm: hasAnyValue ? formData : null,
|
endpointSpecSwarm: hasAnyValue ? formData : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -16,29 +16,17 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const optionalNumber = z
|
|
||||||
.union([z.string(), z.number()])
|
|
||||||
.transform((val) => (val === "" ? undefined : Number(val)))
|
|
||||||
.optional();
|
|
||||||
|
|
||||||
export const healthCheckFormSchema = z.object({
|
export const healthCheckFormSchema = z.object({
|
||||||
Test: z.array(z.string()).optional(),
|
Test: z.array(z.string()).optional(),
|
||||||
Interval: optionalNumber,
|
Interval: z.coerce.number().optional(),
|
||||||
Timeout: optionalNumber,
|
Timeout: z.coerce.number().optional(),
|
||||||
StartPeriod: optionalNumber,
|
StartPeriod: z.coerce.number().optional(),
|
||||||
Retries: optionalNumber,
|
Retries: z.coerce.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
interface HealthCheckFormProps {
|
interface HealthCheckFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||||
@@ -54,7 +42,6 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -67,7 +54,6 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -118,7 +104,6 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
healthCheckSwarm: hasAnyValue ? formData : null,
|
healthCheckSwarm: hasAnyValue ? formData : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -200,12 +185,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
Time between health checks (e.g., 10000000000 for 10 seconds)
|
Time between health checks (e.g., 10000000000 for 10 seconds)
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
type="number"
|
|
||||||
placeholder="10000000000"
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -222,12 +202,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
Maximum time to wait for health check response
|
Maximum time to wait for health check response
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
type="number"
|
|
||||||
placeholder="10000000000"
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -244,12 +219,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
Initial grace period before health checks begin
|
Initial grace period before health checks begin
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
type="number"
|
|
||||||
placeholder="10000000000"
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -267,12 +237,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
|||||||
unhealthy
|
unhealthy
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input type="number" placeholder="3" {...field} />
|
||||||
type="number"
|
|
||||||
placeholder="3"
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -29,14 +29,7 @@ export const labelsFormSchema = z.object({
|
|||||||
|
|
||||||
interface LabelsFormProps {
|
interface LabelsFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
||||||
@@ -52,7 +45,6 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -65,7 +57,6 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -121,7 +112,6 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
labelsSwarm: labelsToSend,
|
labelsSwarm: labelsToSend,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,7 @@ import { api } from "@/utils/api";
|
|||||||
|
|
||||||
interface ModeFormProps {
|
interface ModeFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ModeForm = ({ id, type }: ModeFormProps) => {
|
export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||||
@@ -46,7 +39,6 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -59,7 +51,6 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -104,7 +95,6 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
modeSwarm: null,
|
modeSwarm: null,
|
||||||
});
|
});
|
||||||
toast.success("Mode updated successfully");
|
toast.success("Mode updated successfully");
|
||||||
@@ -115,14 +105,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
|||||||
|
|
||||||
const modeData =
|
const modeData =
|
||||||
formData.type === "Replicated"
|
formData.type === "Replicated"
|
||||||
? {
|
? { Replicated: { Replicas: formData.Replicas } }
|
||||||
Replicated: {
|
|
||||||
Replicas:
|
|
||||||
formData.Replicas !== undefined && formData.Replicas !== ""
|
|
||||||
? Number(formData.Replicas)
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: { Global: {} };
|
: { Global: {} };
|
||||||
|
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
@@ -132,7 +115,6 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
modeSwarm: modeData,
|
modeSwarm: modeData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -35,14 +35,7 @@ export const networkFormSchema = z.object({
|
|||||||
|
|
||||||
interface NetworkFormProps {
|
interface NetworkFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
||||||
@@ -58,7 +51,6 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -71,7 +63,6 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -141,7 +132,6 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
networkSwarm: networksToSend,
|
networkSwarm: networksToSend,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -34,14 +34,7 @@ export const placementFormSchema = z.object({
|
|||||||
|
|
||||||
interface PlacementFormProps {
|
interface PlacementFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
||||||
@@ -57,7 +50,6 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -70,7 +62,6 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -123,7 +114,6 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
placementSwarm: hasAnyValue
|
placementSwarm: hasAnyValue
|
||||||
? {
|
? {
|
||||||
...formData,
|
...formData,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -32,14 +32,7 @@ export const restartPolicyFormSchema = z.object({
|
|||||||
|
|
||||||
interface RestartPolicyFormProps {
|
interface RestartPolicyFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
||||||
@@ -55,7 +48,6 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -68,7 +60,6 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -113,7 +104,6 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
restartPolicySwarm: hasAnyValue ? formData : null,
|
restartPolicySwarm: hasAnyValue ? formData : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -34,14 +34,7 @@ export const rollbackConfigFormSchema = z.object({
|
|||||||
|
|
||||||
interface RollbackConfigFormProps {
|
interface RollbackConfigFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
||||||
@@ -57,7 +50,6 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -70,7 +62,6 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -112,7 +103,6 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
|
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,21 +16,14 @@ import { api } from "@/utils/api";
|
|||||||
|
|
||||||
const hasStopGracePeriodSwarm = (
|
const hasStopGracePeriodSwarm = (
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): value is { stopGracePeriodSwarm: number | string | null } =>
|
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
|
||||||
typeof value === "object" &&
|
typeof value === "object" &&
|
||||||
value !== null &&
|
value !== null &&
|
||||||
"stopGracePeriodSwarm" in value;
|
"stopGracePeriodSwarm" in value;
|
||||||
|
|
||||||
interface StopGracePeriodFormProps {
|
interface StopGracePeriodFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||||
@@ -46,7 +39,6 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -59,7 +51,6 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -68,7 +59,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
|
|
||||||
const form = useForm<any>({
|
const form = useForm<any>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
value: null as number | null,
|
value: null as bigint | null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,7 +67,11 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
if (hasStopGracePeriodSwarm(data)) {
|
if (hasStopGracePeriodSwarm(data)) {
|
||||||
const value = data.stopGracePeriodSwarm;
|
const value = data.stopGracePeriodSwarm;
|
||||||
const normalizedValue =
|
const normalizedValue =
|
||||||
value === null || value === undefined ? null : Number(value);
|
value === null || value === undefined
|
||||||
|
? null
|
||||||
|
: typeof value === "bigint"
|
||||||
|
? value
|
||||||
|
: BigInt(value);
|
||||||
form.reset({
|
form.reset({
|
||||||
value: normalizedValue,
|
value: normalizedValue,
|
||||||
});
|
});
|
||||||
@@ -93,7 +88,6 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
stopGracePeriodSwarm: formData.value,
|
stopGracePeriodSwarm: formData.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,7 +126,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
|||||||
}
|
}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.onChange(
|
field.onChange(
|
||||||
e.target.value ? Number(e.target.value) : null,
|
e.target.value ? BigInt(e.target.value) : null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -34,14 +34,7 @@ export const updateConfigFormSchema = z.object({
|
|||||||
|
|
||||||
interface UpdateConfigFormProps {
|
interface UpdateConfigFormProps {
|
||||||
id: string;
|
id: string;
|
||||||
type:
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
| "postgres"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "redis"
|
|
||||||
| "application"
|
|
||||||
| "libsql";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
||||||
@@ -57,7 +50,6 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
|||||||
application: () =>
|
application: () =>
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -70,7 +62,6 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
|||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
application: () => api.application.update.useMutation(),
|
application: () => api.application.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync } = mutationMap[type]
|
const { mutateAsync } = mutationMap[type]
|
||||||
@@ -118,7 +109,6 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
|||||||
mysqlId: id || "",
|
mysqlId: id || "",
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
libsqlId: id || "",
|
|
||||||
updateConfigSwarm: (hasAnyValue ? formData : null) as any,
|
updateConfigSwarm: (hasAnyValue ? formData : null) as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
@@ -50,7 +50,7 @@ export const AddCommand = ({ applicationId }: Props) => {
|
|||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const { mutateAsync, isPending } = api.application.update.useMutation();
|
const { mutateAsync, isLoading } = api.application.update.useMutation();
|
||||||
|
|
||||||
const form = useForm<AddCommand>({
|
const form = useForm<AddCommand>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -177,7 +177,7 @@ export const AddCommand = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button isLoading={isPending} type="submit" className="w-fit">
|
<Button isLoading={isLoading} type="submit" className="w-fit">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Code2, Globe2, HardDrive } from "lucide-react";
|
import { Code2, Globe2, HardDrive } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -69,11 +69,11 @@ export const ShowImport = ({ composeId }: Props) => {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { mutateAsync: processTemplate, isPending: isLoadingTemplate } =
|
const { mutateAsync: processTemplate, isLoading: isLoadingTemplate } =
|
||||||
api.compose.processTemplate.useMutation();
|
api.compose.processTemplate.useMutation();
|
||||||
const {
|
const {
|
||||||
mutateAsync: importTemplate,
|
mutateAsync: importTemplate,
|
||||||
isPending: isImporting,
|
isLoading: isImporting,
|
||||||
isSuccess: isImportSuccess,
|
isSuccess: isImportSuccess,
|
||||||
} = api.compose.import.useMutation();
|
} = api.compose.import.useMutation();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm, useWatch } from "react-hook-form";
|
import { useForm, useWatch } from "react-hook-form";
|
||||||
@@ -35,9 +35,13 @@ import { api } from "@/utils/api";
|
|||||||
|
|
||||||
const AddPortSchema = z.object({
|
const AddPortSchema = z.object({
|
||||||
publishedPort: z.number().int().min(1).max(65535),
|
publishedPort: z.number().int().min(1).max(65535),
|
||||||
publishMode: z.enum(["ingress", "host"]),
|
publishMode: z.enum(["ingress", "host"], {
|
||||||
|
required_error: "Publish mode is required",
|
||||||
|
}),
|
||||||
targetPort: z.number().int().min(1).max(65535),
|
targetPort: z.number().int().min(1).max(65535),
|
||||||
protocol: z.enum(["tcp", "udp"]),
|
protocol: z.enum(["tcp", "udp"], {
|
||||||
|
required_error: "Protocol is required",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddPort = z.infer<typeof AddPortSchema>;
|
type AddPort = z.infer<typeof AddPortSchema>;
|
||||||
@@ -64,7 +68,7 @@ export const HandlePorts = ({
|
|||||||
enabled: !!portId,
|
enabled: !!portId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const { mutateAsync, isPending, error, isError } = portId
|
const { mutateAsync, isLoading, error, isError } = portId
|
||||||
? api.port.update.useMutation()
|
? api.port.update.useMutation()
|
||||||
: api.port.create.useMutation();
|
: api.port.create.useMutation();
|
||||||
|
|
||||||
@@ -266,7 +270,7 @@ export const HandlePorts = ({
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isPending}
|
isLoading={isLoading}
|
||||||
form="hook-form-add-port"
|
form="hook-form-add-port"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const ShowPorts = ({ applicationId }: Props) => {
|
|||||||
{ enabled: !!applicationId },
|
{ enabled: !!applicationId },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync: deletePort, isPending: isRemoving } =
|
const { mutateAsync: deletePort, isLoading: isRemoving } =
|
||||||
api.port.delete.useMutation();
|
api.port.delete.useMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -37,13 +37,13 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const AddRedirectSchema = z.object({
|
const AddRedirectchema = z.object({
|
||||||
regex: z.string().min(1, "Regex required"),
|
regex: z.string().min(1, "Regex required"),
|
||||||
permanent: z.boolean().default(false),
|
permanent: z.boolean().default(false),
|
||||||
replacement: z.string().min(1, "Replacement required"),
|
replacement: z.string().min(1, "Replacement required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddRedirect = z.infer<typeof AddRedirectSchema>;
|
type AddRedirect = z.infer<typeof AddRedirectchema>;
|
||||||
|
|
||||||
// Default presets
|
// Default presets
|
||||||
const redirectPresets = [
|
const redirectPresets = [
|
||||||
@@ -100,17 +100,17 @@ export const HandleRedirect = ({
|
|||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const { mutateAsync, isPending, error, isError } = redirectId
|
const { mutateAsync, isLoading, error, isError } = redirectId
|
||||||
? api.redirects.update.useMutation()
|
? api.redirects.update.useMutation()
|
||||||
: api.redirects.create.useMutation();
|
: api.redirects.create.useMutation();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm<AddRedirect>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
regex: "",
|
regex: "",
|
||||||
replacement: "",
|
replacement: "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(AddRedirectSchema),
|
resolver: zodResolver(AddRedirectchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -149,7 +149,7 @@ export const HandleRedirect = ({
|
|||||||
|
|
||||||
const onDialogToggle = (open: boolean) => {
|
const onDialogToggle = (open: boolean) => {
|
||||||
setIsOpen(open);
|
setIsOpen(open);
|
||||||
// commented for the moment because not resetting the form if accidentally closed the dialog can be considered as a feature instead of a bug
|
// commented for the moment because not reseting the form if accidentally closed the dialog can be considered as a feature instead of a bug
|
||||||
// setPresetSelected("");
|
// setPresetSelected("");
|
||||||
// form.reset();
|
// form.reset();
|
||||||
};
|
};
|
||||||
@@ -268,7 +268,7 @@ export const HandleRedirect = ({
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isPending}
|
isLoading={isLoading}
|
||||||
form="hook-form-add-redirect"
|
form="hook-form-add-redirect"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const ShowRedirects = ({ applicationId }: Props) => {
|
|||||||
{ enabled: !!applicationId },
|
{ enabled: !!applicationId },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync: deleteRedirect, isPending: isRemoving } =
|
const { mutateAsync: deleteRedirect, isLoading: isRemoving } =
|
||||||
api.redirects.delete.useMutation();
|
api.redirects.delete.useMutation();
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -46,7 +46,7 @@ export const HandleSecurity = ({
|
|||||||
}: Props) => {
|
}: Props) => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { data, refetch } = api.security.one.useQuery(
|
const { data } = api.security.one.useQuery(
|
||||||
{
|
{
|
||||||
securityId: securityId ?? "",
|
securityId: securityId ?? "",
|
||||||
},
|
},
|
||||||
@@ -55,7 +55,7 @@ export const HandleSecurity = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync, isPending, error, isError } = securityId
|
const { mutateAsync, isLoading, error, isError } = securityId
|
||||||
? api.security.update.useMutation()
|
? api.security.update.useMutation()
|
||||||
: api.security.create.useMutation();
|
: api.security.create.useMutation();
|
||||||
|
|
||||||
@@ -88,7 +88,6 @@ export const HandleSecurity = ({
|
|||||||
await utils.application.readTraefikConfig.invalidate({
|
await utils.application.readTraefikConfig.invalidate({
|
||||||
applicationId,
|
applicationId,
|
||||||
});
|
});
|
||||||
await refetch();
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -164,7 +163,7 @@ export const HandleSecurity = ({
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isPending}
|
isLoading={isLoading}
|
||||||
form="hook-form-add-security"
|
form="hook-form-add-security"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const ShowSecurity = ({ applicationId }: Props) => {
|
|||||||
{ enabled: !!applicationId },
|
{ enabled: !!applicationId },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync: deleteSecurity, isPending: isRemoving } =
|
const { mutateAsync: deleteSecurity, isLoading: isRemoving } =
|
||||||
api.security.delete.useMutation();
|
api.security.delete.useMutation();
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Server } from "lucide-react";
|
import { Server } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -74,7 +74,7 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
|
|||||||
const { data: buildServers } = api.server.buildServers.useQuery();
|
const { data: buildServers } = api.server.buildServers.useQuery();
|
||||||
const { data: registries } = api.registry.all.useQuery();
|
const { data: registries } = api.registry.all.useQuery();
|
||||||
|
|
||||||
const { mutateAsync, isPending } = api.application.update.useMutation();
|
const { mutateAsync, isLoading } = api.application.update.useMutation();
|
||||||
|
|
||||||
const form = useForm<Schema>({
|
const form = useForm<Schema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -274,7 +274,7 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button isLoading={isPending} type="submit">
|
<Button isLoading={isLoading} type="submit">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { InfoIcon, Plus, Trash2 } from "lucide-react";
|
import { InfoIcon, Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
@@ -89,13 +89,12 @@ const ULIMIT_PRESETS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export type ServiceType =
|
export type ServiceType =
|
||||||
| "application"
|
|
||||||
| "libsql"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "postgres"
|
| "postgres"
|
||||||
| "redis";
|
| "mongo"
|
||||||
|
| "redis"
|
||||||
|
| "mysql"
|
||||||
|
| "mariadb"
|
||||||
|
| "application";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -106,36 +105,34 @@ type AddResources = z.infer<typeof addResourcesSchema>;
|
|||||||
|
|
||||||
export const ShowResources = ({ id, type }: Props) => {
|
export const ShowResources = ({ id, type }: Props) => {
|
||||||
const queryMap = {
|
const queryMap = {
|
||||||
application: () =>
|
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
mariadb: () =>
|
|
||||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
|
||||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
|
||||||
postgres: () =>
|
postgres: () =>
|
||||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
const mutationMap = {
|
const mutationMap = {
|
||||||
application: () => api.application.update.useMutation(),
|
|
||||||
libsql: () => api.libsql.update.useMutation(),
|
|
||||||
mariadb: () => api.mariadb.update.useMutation(),
|
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
|
||||||
mysql: () => api.mysql.update.useMutation(),
|
|
||||||
postgres: () => api.postgres.update.useMutation(),
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
redis: () => api.redis.update.useMutation(),
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync, isPending } = mutationMap[type]
|
const { mutateAsync, isLoading } = mutationMap[type]
|
||||||
? mutationMap[type]()
|
? mutationMap[type]()
|
||||||
: api.mongo.update.useMutation();
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm<AddResources>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
cpuLimit: "",
|
cpuLimit: "",
|
||||||
cpuReservation: "",
|
cpuReservation: "",
|
||||||
@@ -158,20 +155,19 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
cpuReservation: data?.cpuReservation || undefined,
|
cpuReservation: data?.cpuReservation || undefined,
|
||||||
memoryLimit: data?.memoryLimit || undefined,
|
memoryLimit: data?.memoryLimit || undefined,
|
||||||
memoryReservation: data?.memoryReservation || undefined,
|
memoryReservation: data?.memoryReservation || undefined,
|
||||||
ulimitsSwarm: (data as any)?.ulimitsSwarm || [],
|
ulimitsSwarm: data?.ulimitsSwarm || [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form, form.reset]);
|
}, [data, form, form.reset]);
|
||||||
|
|
||||||
const onSubmit = async (formData: AddResources) => {
|
const onSubmit = async (formData: AddResources) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
applicationId: id || "",
|
|
||||||
libsqlId: id || "",
|
|
||||||
mariadbId: id || "",
|
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
mysqlId: id || "",
|
|
||||||
postgresId: id || "",
|
postgresId: id || "",
|
||||||
redisId: id || "",
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
applicationId: id || "",
|
||||||
cpuLimit: formData.cpuLimit || null,
|
cpuLimit: formData.cpuLimit || null,
|
||||||
cpuReservation: formData.cpuReservation || null,
|
cpuReservation: formData.cpuReservation || null,
|
||||||
memoryLimit: formData.memoryLimit || null,
|
memoryLimit: formData.memoryLimit || null,
|
||||||
@@ -224,7 +220,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<FormLabel>Memory Limit</FormLabel>
|
<FormLabel>Memory Limit</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -263,7 +259,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<FormLabel>Memory Reservation</FormLabel>
|
<FormLabel>Memory Reservation</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -303,7 +299,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<FormLabel>CPU Limit</FormLabel>
|
<FormLabel>CPU Limit</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -343,7 +339,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<FormLabel>CPU Reservation</FormLabel>
|
<FormLabel>CPU Reservation</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -379,7 +375,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<FormLabel className="text-base">Ulimits</FormLabel>
|
<FormLabel className="text-base">Ulimits</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger type="button">
|
<TooltipTrigger>
|
||||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-xs">
|
<TooltipContent className="max-w-xs">
|
||||||
@@ -456,11 +452,6 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
min={-1}
|
min={-1}
|
||||||
placeholder="65535"
|
placeholder="65535"
|
||||||
{...field}
|
{...field}
|
||||||
value={
|
|
||||||
typeof field.value === "number"
|
|
||||||
? field.value
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.onChange(Number(e.target.value))
|
field.onChange(Number(e.target.value))
|
||||||
}
|
}
|
||||||
@@ -484,11 +475,6 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
min={-1}
|
min={-1}
|
||||||
placeholder="65535"
|
placeholder="65535"
|
||||||
{...field}
|
{...field}
|
||||||
value={
|
|
||||||
typeof field.value === "number"
|
|
||||||
? field.value
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.onChange(Number(e.target.value))
|
field.onChange(Number(e.target.value))
|
||||||
}
|
}
|
||||||
@@ -521,7 +507,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button isLoading={isPending} type="submit">
|
<Button isLoading={isLoading} type="submit">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,17 +15,13 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { data, isLoading } = api.application.readTraefikConfig.useQuery(
|
||||||
const canRead = permissions?.traefikFiles.read ?? false;
|
|
||||||
const { data, isPending } = api.application.readTraefikConfig.useQuery(
|
|
||||||
{
|
{
|
||||||
applicationId,
|
applicationId,
|
||||||
},
|
},
|
||||||
{ enabled: !!applicationId && canRead },
|
{ enabled: !!applicationId },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!canRead) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader className="flex flex-row justify-between">
|
<CardHeader className="flex flex-row justify-between">
|
||||||
@@ -39,7 +35,7 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
{isPending ? (
|
{isLoading ? (
|
||||||
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center justify-center min-h-[10vh]">
|
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center justify-center min-h-[10vh]">
|
||||||
Loading...
|
Loading...
|
||||||
<Loader2 className="animate-spin" />
|
<Loader2 className="animate-spin" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -7,7 +7,6 @@ import { z } from "zod";
|
|||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -25,6 +24,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
@@ -60,8 +60,6 @@ export const validateAndFormatYAML = (yamlText: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
|
||||||
const canWrite = permissions?.traefikFiles.write ?? false;
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
|
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
|
||||||
const { data, refetch } = api.application.readTraefikConfig.useQuery(
|
const { data, refetch } = api.application.readTraefikConfig.useQuery(
|
||||||
@@ -71,7 +69,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
|||||||
{ enabled: !!applicationId },
|
{ enabled: !!applicationId },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync, isPending, error, isError } =
|
const { mutateAsync, isLoading, error, isError } =
|
||||||
api.application.updateTraefikConfig.useMutation();
|
api.application.updateTraefikConfig.useMutation();
|
||||||
|
|
||||||
const form = useForm<UpdateTraefikConfig>({
|
const form = useForm<UpdateTraefikConfig>({
|
||||||
@@ -127,11 +125,9 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{canWrite && (
|
<DialogTrigger asChild>
|
||||||
<DialogTrigger asChild>
|
<Button isLoading={isLoading}>Modify</Button>
|
||||||
<Button isLoading={isPending}>Modify</Button>
|
</DialogTrigger>
|
||||||
</DialogTrigger>
|
|
||||||
)}
|
|
||||||
<DialogContent className="sm:max-w-4xl">
|
<DialogContent className="sm:max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Update traefik config</DialogTitle>
|
<DialogTitle>Update traefik config</DialogTitle>
|
||||||
@@ -202,7 +198,7 @@ routers:
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isPending}
|
isLoading={isLoading}
|
||||||
form="hook-form-update-traefik-config"
|
form="hook-form-update-traefik-config"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -34,13 +34,13 @@ interface Props {
|
|||||||
serviceId: string;
|
serviceId: string;
|
||||||
serviceType:
|
serviceType:
|
||||||
| "application"
|
| "application"
|
||||||
| "compose"
|
|
||||||
| "libsql"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "postgres"
|
| "postgres"
|
||||||
| "redis";
|
| "redis"
|
||||||
|
| "mongo"
|
||||||
|
| "redis"
|
||||||
|
| "mysql"
|
||||||
|
| "mariadb"
|
||||||
|
| "compose";
|
||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,33 +21,24 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowVolumes = ({ id, type }: Props) => {
|
export const ShowVolumes = ({ id, type }: Props) => {
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
|
||||||
const canRead = permissions?.volume.read ?? false;
|
|
||||||
const canCreate = permissions?.volume.create ?? false;
|
|
||||||
const canDelete = permissions?.volume.delete ?? false;
|
|
||||||
|
|
||||||
if (!canRead) return null;
|
|
||||||
|
|
||||||
const queryMap = {
|
const queryMap = {
|
||||||
application: () =>
|
|
||||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
|
||||||
compose: () =>
|
|
||||||
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
mariadb: () =>
|
|
||||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
|
||||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
|
||||||
postgres: () =>
|
postgres: () =>
|
||||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
compose: () =>
|
||||||
|
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
const { mutateAsync: deleteVolume, isPending: isRemoving } =
|
const { mutateAsync: deleteVolume, isLoading: isRemoving } =
|
||||||
api.mounts.remove.useMutation();
|
api.mounts.remove.useMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
|
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
|
||||||
@@ -59,7 +50,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canCreate && data && data?.mounts.length > 0 && (
|
{data && data?.mounts.length > 0 && (
|
||||||
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
||||||
Add Volume
|
Add Volume
|
||||||
</AddVolumes>
|
</AddVolumes>
|
||||||
@@ -72,11 +63,9 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
|||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
No volumes/mounts configured
|
No volumes/mounts configured
|
||||||
</span>
|
</span>
|
||||||
{canCreate && (
|
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
||||||
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
Add Volume
|
||||||
Add Volume
|
</AddVolumes>
|
||||||
</AddVolumes>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col pt-2 gap-4">
|
<div className="flex flex-col pt-2 gap-4">
|
||||||
@@ -141,42 +130,38 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-1">
|
<div className="flex flex-row gap-1">
|
||||||
{canCreate && (
|
<UpdateVolume
|
||||||
<UpdateVolume
|
mountId={mount.mountId}
|
||||||
mountId={mount.mountId}
|
type={mount.type}
|
||||||
type={mount.type}
|
refetch={refetch}
|
||||||
refetch={refetch}
|
serviceType={type}
|
||||||
serviceType={type}
|
/>
|
||||||
/>
|
<DialogAction
|
||||||
)}
|
title="Delete Volume"
|
||||||
{canDelete && (
|
description="Are you sure you want to delete this volume?"
|
||||||
<DialogAction
|
type="destructive"
|
||||||
title="Delete Volume"
|
onClick={async () => {
|
||||||
description="Are you sure you want to delete this volume?"
|
await deleteVolume({
|
||||||
type="destructive"
|
mountId: mount.mountId,
|
||||||
onClick={async () => {
|
})
|
||||||
await deleteVolume({
|
.then(() => {
|
||||||
mountId: mount.mountId,
|
refetch();
|
||||||
|
toast.success("Volume deleted successfully");
|
||||||
})
|
})
|
||||||
.then(() => {
|
.catch(() => {
|
||||||
refetch();
|
toast.error("Error deleting volume");
|
||||||
toast.success("Volume deleted successfully");
|
});
|
||||||
})
|
}}
|
||||||
.catch(() => {
|
>
|
||||||
toast.error("Error deleting volume");
|
<Button
|
||||||
});
|
variant="ghost"
|
||||||
}}
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10"
|
||||||
|
isLoading={isRemoving}
|
||||||
>
|
>
|
||||||
<Button
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="icon"
|
</DialogAction>
|
||||||
className="group hover:bg-red-500/10"
|
|
||||||
isLoading={isRemoving}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { PenBoxIcon } from "lucide-react";
|
import { PenBoxIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -67,13 +67,13 @@ interface Props {
|
|||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
serviceType:
|
serviceType:
|
||||||
| "application"
|
| "application"
|
||||||
| "compose"
|
|
||||||
| "libsql"
|
|
||||||
| "mariadb"
|
|
||||||
| "mongo"
|
|
||||||
| "mysql"
|
|
||||||
| "postgres"
|
| "postgres"
|
||||||
| "redis";
|
| "redis"
|
||||||
|
| "mongo"
|
||||||
|
| "redis"
|
||||||
|
| "mysql"
|
||||||
|
| "mariadb"
|
||||||
|
| "compose";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UpdateVolume = ({
|
export const UpdateVolume = ({
|
||||||
@@ -93,7 +93,7 @@ export const UpdateVolume = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync, isPending, error, isError } =
|
const { mutateAsync, isLoading, error, isError } =
|
||||||
api.mounts.update.useMutation();
|
api.mounts.update.useMutation();
|
||||||
|
|
||||||
const form = useForm<UpdateMount>({
|
const form = useForm<UpdateMount>({
|
||||||
@@ -187,7 +187,7 @@ export const UpdateVolume = ({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="group hover:bg-blue-500/10 "
|
className="group hover:bg-blue-500/10 "
|
||||||
isLoading={isPending}
|
isLoading={isLoading}
|
||||||
>
|
>
|
||||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -253,7 +253,7 @@ export const UpdateVolume = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="content"
|
name="content"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="w-full max-w-[45rem]">
|
<FormItem className="max-w-full max-w-[45rem]">
|
||||||
<FormLabel>Content</FormLabel>
|
<FormLabel>Content</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -310,7 +310,7 @@ PORT=3000
|
|||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isPending}
|
isLoading={isLoading}
|
||||||
// form="hook-form-update-volume"
|
// form="hook-form-update-volume"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Cog } from "lucide-react";
|
import { Cog } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -74,7 +74,12 @@ const buildTypeDisplayMap: Record<BuildType, string> = {
|
|||||||
const mySchema = z.discriminatedUnion("buildType", [
|
const mySchema = z.discriminatedUnion("buildType", [
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal(BuildType.dockerfile),
|
buildType: z.literal(BuildType.dockerfile),
|
||||||
dockerfile: z.string().nullable().default(""),
|
dockerfile: z
|
||||||
|
.string({
|
||||||
|
required_error: "Dockerfile path is required",
|
||||||
|
invalid_type_error: "Dockerfile path is required",
|
||||||
|
})
|
||||||
|
.min(1, "Dockerfile required"),
|
||||||
dockerContextPath: z.string().nullable().default(""),
|
dockerContextPath: z.string().nullable().default(""),
|
||||||
dockerBuildStage: z.string().nullable().default(""),
|
dockerBuildStage: z.string().nullable().default(""),
|
||||||
}),
|
}),
|
||||||
@@ -163,14 +168,14 @@ const resetData = (data: ApplicationData): AddTemplate => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||||
const { mutateAsync, isPending } =
|
const { mutateAsync, isLoading } =
|
||||||
api.application.saveBuildType.useMutation();
|
api.application.saveBuildType.useMutation();
|
||||||
const { data, refetch } = api.application.one.useQuery(
|
const { data, refetch } = api.application.one.useQuery(
|
||||||
{ applicationId },
|
{ applicationId },
|
||||||
{ enabled: !!applicationId },
|
{ enabled: !!applicationId },
|
||||||
);
|
);
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm<AddTemplate>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
buildType: BuildType.nixpacks,
|
buildType: BuildType.nixpacks,
|
||||||
},
|
},
|
||||||
@@ -342,7 +347,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Docker File</FormLabel>
|
<FormLabel>Docker File</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Path of your docker file (default: Dockerfile)"
|
placeholder="Path of your docker file"
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
/>
|
/>
|
||||||
@@ -528,7 +533,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button isLoading={isPending} type="submit">
|
<Button isLoading={isLoading} type="submit">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Ban } from "lucide-react";
|
import { Paintbrush } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -20,7 +20,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CancelQueues = ({ id, type }: Props) => {
|
export const CancelQueues = ({ id, type }: Props) => {
|
||||||
const { mutateAsync, isPending } =
|
const { mutateAsync, isLoading } =
|
||||||
type === "application"
|
type === "application"
|
||||||
? api.application.cleanQueues.useMutation()
|
? api.application.cleanQueues.useMutation()
|
||||||
: api.compose.cleanQueues.useMutation();
|
: api.compose.cleanQueues.useMutation();
|
||||||
@@ -33,9 +33,9 @@ export const CancelQueues = ({ id, type }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive" className="w-fit" isLoading={isPending}>
|
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
|
||||||
Cancel Queues
|
Cancel Queues
|
||||||
<Ban className="size-4" />
|
<Paintbrush className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import { Paintbrush } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: string;
|
|
||||||
type: "application" | "compose";
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ClearDeployments = ({ id, type }: Props) => {
|
|
||||||
const utils = api.useUtils();
|
|
||||||
const { mutateAsync, isPending } =
|
|
||||||
type === "application"
|
|
||||||
? api.application.clearDeployments.useMutation()
|
|
||||||
: api.compose.clearDeployments.useMutation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button variant="outline" className="w-fit" isLoading={isPending}>
|
|
||||||
Clear deployments
|
|
||||||
<Paintbrush className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
Are you sure you want to clear old deployments?
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This will delete all old deployment records and logs, keeping only
|
|
||||||
the active deployment (the most recent successful one).
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={async () => {
|
|
||||||
await mutateAsync({
|
|
||||||
applicationId: id || "",
|
|
||||||
composeId: id || "",
|
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
toast.success("Old deployments cleared successfully");
|
|
||||||
await utils.deployment.allByType.invalidate({
|
|
||||||
id,
|
|
||||||
type: type as "application" | "compose",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
toast.error(err.message);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -20,7 +20,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const KillBuild = ({ id, type }: Props) => {
|
export const KillBuild = ({ id, type }: Props) => {
|
||||||
const { mutateAsync, isPending } =
|
const { mutateAsync, isLoading } =
|
||||||
type === "application"
|
type === "application"
|
||||||
? api.application.killBuild.useMutation()
|
? api.application.killBuild.useMutation()
|
||||||
: api.compose.killBuild.useMutation();
|
: api.compose.killBuild.useMutation();
|
||||||
@@ -28,7 +28,7 @@ export const KillBuild = ({ id, type }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="outline" className="w-fit" isLoading={isPending}>
|
<Button variant="outline" className="w-fit" isLoading={isLoading}>
|
||||||
Kill Build
|
Kill Build
|
||||||
<Scissors className="size-4" />
|
<Scissors className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { Check, Copy, Loader2 } from "lucide-react";
|
import { Check, Copy, Loader2 } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { AnalyzeLogs } from "@/components/dashboard/docker/logs/analyze-logs";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
@@ -166,7 +165,6 @@ export const ShowDeployment = ({
|
|||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<AnalyzeLogs logs={filteredLogs} context="build" />
|
|
||||||
|
|
||||||
{serverId && (
|
{serverId && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
@@ -196,21 +194,13 @@ export const ShowDeployment = ({
|
|||||||
{" "}
|
{" "}
|
||||||
{filteredLogs.length > 0 ? (
|
{filteredLogs.length > 0 ? (
|
||||||
filteredLogs.map((log: LogLine, index: number) => (
|
filteredLogs.map((log: LogLine, index: number) => (
|
||||||
<TerminalLine
|
<TerminalLine key={index} log={log} noTimestamp />
|
||||||
key={`${log.rawTimestamp ?? ""}-${index}`}
|
|
||||||
log={log}
|
|
||||||
noTimestamp
|
|
||||||
/>
|
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{optionalErrors.length > 0 ? (
|
{optionalErrors.length > 0 ? (
|
||||||
optionalErrors.map((log: LogLine, index: number) => (
|
optionalErrors.map((log: LogLine, index: number) => (
|
||||||
<TerminalLine
|
<TerminalLine key={`extra-${index}`} log={log} noTimestamp />
|
||||||
key={`extra-${log.rawTimestamp ?? ""}-${index}`}
|
|
||||||
log={log}
|
|
||||||
noTimestamp
|
|
||||||
/>
|
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="flex justify-center items-center h-full text-muted-foreground">
|
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import copy from "copy-to-clipboard";
|
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Clock,
|
Clock,
|
||||||
Copy,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
Settings,
|
Settings,
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -28,7 +25,6 @@ import {
|
|||||||
import { api, type RouterOutputs } from "@/utils/api";
|
import { api, type RouterOutputs } from "@/utils/api";
|
||||||
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
|
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
|
||||||
import { CancelQueues } from "./cancel-queues";
|
import { CancelQueues } from "./cancel-queues";
|
||||||
import { ClearDeployments } from "./clear-deployments";
|
|
||||||
import { KillBuild } from "./kill-build";
|
import { KillBuild } from "./kill-build";
|
||||||
import { RefreshToken } from "./refresh-token";
|
import { RefreshToken } from "./refresh-token";
|
||||||
import { ShowDeployment } from "./show-deployment";
|
import { ShowDeployment } from "./show-deployment";
|
||||||
@@ -63,7 +59,7 @@ export const ShowDeployments = ({
|
|||||||
const [activeLog, setActiveLog] = useState<
|
const [activeLog, setActiveLog] = useState<
|
||||||
RouterOutputs["deployment"]["all"][number] | null
|
RouterOutputs["deployment"]["all"][number] | null
|
||||||
>(null);
|
>(null);
|
||||||
const { data: deployments, isPending: isLoadingDeployments } =
|
const { data: deployments, isLoading: isLoadingDeployments } =
|
||||||
api.deployment.allByType.useQuery(
|
api.deployment.allByType.useQuery(
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -77,21 +73,19 @@ export const ShowDeployments = ({
|
|||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
|
||||||
const { mutateAsync: rollback, isPending: isRollingBack } =
|
const { mutateAsync: rollback, isLoading: isRollingBack } =
|
||||||
api.rollback.rollback.useMutation();
|
api.rollback.rollback.useMutation();
|
||||||
const { mutateAsync: killProcess, isPending: isKillingProcess } =
|
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
|
||||||
api.deployment.killProcess.useMutation();
|
api.deployment.killProcess.useMutation();
|
||||||
const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } =
|
|
||||||
api.deployment.removeDeployment.useMutation();
|
|
||||||
|
|
||||||
// Cancel deployment mutations
|
// Cancel deployment mutations
|
||||||
const {
|
const {
|
||||||
mutateAsync: cancelApplicationDeployment,
|
mutateAsync: cancelApplicationDeployment,
|
||||||
isPending: isCancellingApp,
|
isLoading: isCancellingApp,
|
||||||
} = api.application.cancelDeployment.useMutation();
|
} = api.application.cancelDeployment.useMutation();
|
||||||
const {
|
const {
|
||||||
mutateAsync: cancelComposeDeployment,
|
mutateAsync: cancelComposeDeployment,
|
||||||
isPending: isCancellingCompose,
|
isLoading: isCancellingCompose,
|
||||||
} = api.compose.cancelDeployment.useMutation();
|
} = api.compose.cancelDeployment.useMutation();
|
||||||
|
|
||||||
const [url, setUrl] = React.useState("");
|
const [url, setUrl] = React.useState("");
|
||||||
@@ -99,12 +93,6 @@ export const ShowDeployments = ({
|
|||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const webhookUrl = useMemo(
|
|
||||||
() =>
|
|
||||||
`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`,
|
|
||||||
[url, refreshToken, type],
|
|
||||||
);
|
|
||||||
|
|
||||||
const MAX_DESCRIPTION_LENGTH = 200;
|
const MAX_DESCRIPTION_LENGTH = 200;
|
||||||
|
|
||||||
const truncateDescription = (description: string): string => {
|
const truncateDescription = (description: string): string => {
|
||||||
@@ -156,9 +144,6 @@ export const ShowDeployments = ({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center flex-wrap gap-2">
|
<div className="flex flex-row items-center flex-wrap gap-2">
|
||||||
{(type === "application" || type === "compose") && (
|
|
||||||
<ClearDeployments id={id} type={type} />
|
|
||||||
)}
|
|
||||||
{(type === "application" || type === "compose") && (
|
{(type === "application" || type === "compose") && (
|
||||||
<KillBuild id={id} type={type} />
|
<KillBuild id={id} type={type} />
|
||||||
)}
|
)}
|
||||||
@@ -232,27 +217,11 @@ export const ShowDeployments = ({
|
|||||||
<div className="flex flex-row items-center gap-2 flex-wrap">
|
<div className="flex flex-row items-center gap-2 flex-wrap">
|
||||||
<span>Webhook URL: </span>
|
<span>Webhook URL: </span>
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<Badge
|
<span className="break-all text-muted-foreground">
|
||||||
role="button"
|
{`${url}/api/deploy${
|
||||||
tabIndex={0}
|
type === "compose" ? "/compose" : ""
|
||||||
aria-label="Copy webhook URL to clipboard"
|
}/${refreshToken}`}
|
||||||
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer whitespace-normal break-all"
|
</span>
|
||||||
variant="outline"
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
copy(webhookUrl);
|
|
||||||
toast.success("Copied to clipboard.");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
copy(webhookUrl);
|
|
||||||
toast.success("Copied to clipboard.");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{webhookUrl}
|
|
||||||
<Copy className="h-4 w-4 ml-2" />
|
|
||||||
</Badge>
|
|
||||||
{(type === "application" || type === "compose") && (
|
{(type === "application" || type === "compose") && (
|
||||||
<RefreshToken id={id} type={type} />
|
<RefreshToken id={id} type={type} />
|
||||||
)}
|
)}
|
||||||
@@ -283,8 +252,6 @@ export const ShowDeployments = ({
|
|||||||
const isExpanded = expandedDescriptions.has(
|
const isExpanded = expandedDescriptions.has(
|
||||||
deployment.deploymentId,
|
deployment.deploymentId,
|
||||||
);
|
);
|
||||||
const canDelete =
|
|
||||||
deployment.status === "done" || deployment.status === "error";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -403,33 +370,6 @@ export const ShowDeployments = ({
|
|||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{canDelete && (
|
|
||||||
<DialogAction
|
|
||||||
title="Delete Deployment"
|
|
||||||
description="Are you sure you want to delete this deployment? This action cannot be undone."
|
|
||||||
type="default"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await removeDeployment({
|
|
||||||
deploymentId: deployment.deploymentId,
|
|
||||||
});
|
|
||||||
toast.success("Deployment deleted successfully");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Error deleting deployment");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
isLoading={isRemovingDeployment}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{deployment?.rollback &&
|
{deployment?.rollback &&
|
||||||
deployment.status === "done" &&
|
deployment.status === "done" &&
|
||||||
type === "application" && (
|
type === "application" && (
|
||||||
|
|||||||
@@ -1,303 +0,0 @@
|
|||||||
import type { ColumnDef } from "@tanstack/react-table";
|
|
||||||
import {
|
|
||||||
ArrowUpDown,
|
|
||||||
CheckCircle2,
|
|
||||||
ExternalLink,
|
|
||||||
Loader2,
|
|
||||||
PenBoxIcon,
|
|
||||||
RefreshCw,
|
|
||||||
Server,
|
|
||||||
Trash2,
|
|
||||||
XCircle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import type { RouterOutputs } from "@/utils/api";
|
|
||||||
import { DnsHelperModal } from "./dns-helper-modal";
|
|
||||||
import { AddDomain } from "./handle-domain";
|
|
||||||
import type { ValidationStates } from "./show-domains";
|
|
||||||
|
|
||||||
export type Domain =
|
|
||||||
| RouterOutputs["domain"]["byApplicationId"][0]
|
|
||||||
| RouterOutputs["domain"]["byComposeId"][0];
|
|
||||||
|
|
||||||
interface ColumnsProps {
|
|
||||||
id: string;
|
|
||||||
type: "application" | "compose";
|
|
||||||
validationStates: ValidationStates;
|
|
||||||
handleValidateDomain: (host: string) => Promise<void>;
|
|
||||||
handleDeleteDomain: (domainId: string) => Promise<void>;
|
|
||||||
isDeleting: boolean;
|
|
||||||
serverIp?: string;
|
|
||||||
canCreateDomain: boolean;
|
|
||||||
canDeleteDomain: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createColumns = ({
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
validationStates,
|
|
||||||
handleValidateDomain,
|
|
||||||
handleDeleteDomain,
|
|
||||||
isDeleting,
|
|
||||||
serverIp,
|
|
||||||
canCreateDomain,
|
|
||||||
canDeleteDomain,
|
|
||||||
}: ColumnsProps): ColumnDef<Domain>[] => [
|
|
||||||
...(type === "compose"
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
accessorKey: "serviceName",
|
|
||||||
header: "Service",
|
|
||||||
cell: ({ row }: { row: { getValue: (key: string) => unknown } }) => {
|
|
||||||
const serviceName = row.getValue("serviceName") as string | null;
|
|
||||||
if (!serviceName) return null;
|
|
||||||
return (
|
|
||||||
<Badge variant="outline">
|
|
||||||
<Server className="size-3 mr-1" />
|
|
||||||
{serviceName}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
} satisfies ColumnDef<Domain>,
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
|
||||||
accessorKey: "host",
|
|
||||||
header: ({ column }) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Host
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const domain = row.original;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
className="flex items-center gap-2 font-medium hover:underline"
|
|
||||||
target="_blank"
|
|
||||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
|
||||||
>
|
|
||||||
{domain.host}
|
|
||||||
<ExternalLink className="size-3" />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "path",
|
|
||||||
header: ({ column }) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Path
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const path = row.getValue("path") as string;
|
|
||||||
return <div className="font-mono text-sm">{path || "/"}</div>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "port",
|
|
||||||
header: ({ column }) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Port
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const port = row.getValue("port") as number;
|
|
||||||
return <Badge variant="secondary">{port}</Badge>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "customEntrypoint",
|
|
||||||
header: "Entrypoint",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const entrypoint = row.getValue("customEntrypoint") as string | null;
|
|
||||||
if (!entrypoint) return <span className="text-muted-foreground">-</span>;
|
|
||||||
return <div className="font-mono text-sm">{entrypoint}</div>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "https",
|
|
||||||
header: "Protocol",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const https = row.getValue("https") as boolean;
|
|
||||||
return (
|
|
||||||
<Badge variant={https ? "outline" : "secondary"}>
|
|
||||||
{https ? "HTTPS" : "HTTP"}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "certificate",
|
|
||||||
header: "Certificate",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const domain = row.original;
|
|
||||||
const validationState = validationStates[domain.host];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{domain.certificateType && (
|
|
||||||
<Badge variant="outline" className="capitalize">
|
|
||||||
{domain.certificateType}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{!domain.host.includes("sslip.io") && (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={
|
|
||||||
validationState?.isValid
|
|
||||||
? "bg-green-500/10 text-green-500 cursor-pointer"
|
|
||||||
: validationState?.error
|
|
||||||
? "bg-red-500/10 text-red-500 cursor-pointer"
|
|
||||||
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
|
|
||||||
}
|
|
||||||
onClick={() => handleValidateDomain(domain.host)}
|
|
||||||
>
|
|
||||||
{validationState?.isLoading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="size-3 mr-1 animate-spin" />
|
|
||||||
Checking...
|
|
||||||
</>
|
|
||||||
) : validationState?.isValid ? (
|
|
||||||
<>
|
|
||||||
<CheckCircle2 className="size-3 mr-1" />
|
|
||||||
{validationState.message && validationState.cdnProvider
|
|
||||||
? `${validationState.cdnProvider}`
|
|
||||||
: "Valid"}
|
|
||||||
</>
|
|
||||||
) : validationState?.error ? (
|
|
||||||
<>
|
|
||||||
<XCircle className="size-3 mr-1" />
|
|
||||||
Invalid
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="size-3 mr-1" />
|
|
||||||
Validate
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="max-w-xs">
|
|
||||||
{validationState?.error ? (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<p className="font-medium text-red-500">Error:</p>
|
|
||||||
<p>{validationState.error}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
"Click to validate DNS configuration"
|
|
||||||
)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "createdAt",
|
|
||||||
header: ({ column }) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
Created
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const createdAt = row.getValue("createdAt") as string;
|
|
||||||
return (
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{new Date(createdAt).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
header: "Actions",
|
|
||||||
enableHiding: false,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const domain = row.original;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{!domain.host.includes("sslip.io") && (
|
|
||||||
<DnsHelperModal
|
|
||||||
domain={{
|
|
||||||
host: domain.host,
|
|
||||||
https: domain.https,
|
|
||||||
path: domain.path || undefined,
|
|
||||||
}}
|
|
||||||
serverIp={serverIp}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{canCreateDomain && (
|
|
||||||
<AddDomain id={id} type={type} domainId={domain.domainId}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-blue-500/10 h-8 w-8"
|
|
||||||
>
|
|
||||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
|
||||||
</Button>
|
|
||||||
</AddDomain>
|
|
||||||
)}
|
|
||||||
{canDeleteDomain && (
|
|
||||||
<DialogAction
|
|
||||||
title="Delete Domain"
|
|
||||||
description="Are you sure you want to delete this domain?"
|
|
||||||
type="destructive"
|
|
||||||
onClick={async () => {
|
|
||||||
await handleDeleteDomain(domain.domainId);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-red-500/10 h-8 w-8"
|
|
||||||
isLoading={isDeleting}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react";
|
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -62,14 +61,11 @@ export const domain = z
|
|||||||
.min(1, { message: "Port must be at least 1" })
|
.min(1, { message: "Port must be at least 1" })
|
||||||
.max(65535, { message: "Port must be 65535 or below" })
|
.max(65535, { message: "Port must be 65535 or below" })
|
||||||
.optional(),
|
.optional(),
|
||||||
useCustomEntrypoint: z.boolean(),
|
|
||||||
customEntrypoint: z.string().optional(),
|
|
||||||
https: z.boolean().optional(),
|
https: z.boolean().optional(),
|
||||||
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
||||||
customCertResolver: z.string().optional(),
|
customCertResolver: z.string().optional(),
|
||||||
serviceName: z.string().optional(),
|
serviceName: z.string().optional(),
|
||||||
domainType: z.enum(["application", "compose", "preview"]).optional(),
|
domainType: z.enum(["application", "compose", "preview"]).optional(),
|
||||||
middlewares: z.array(z.string()).optional(),
|
|
||||||
})
|
})
|
||||||
.superRefine((input, ctx) => {
|
.superRefine((input, ctx) => {
|
||||||
if (input.https && !input.certificateType) {
|
if (input.https && !input.certificateType) {
|
||||||
@@ -118,14 +114,6 @@ export const domain = z
|
|||||||
message: "Internal path must start with '/'",
|
message: "Internal path must start with '/'",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.useCustomEntrypoint && !input.customEntrypoint) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ["customEntrypoint"],
|
|
||||||
message: "Custom entry point must be specified",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type Domain = z.infer<typeof domain>;
|
type Domain = z.infer<typeof domain>;
|
||||||
@@ -171,11 +159,11 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync, isError, error, isPending } = domainId
|
const { mutateAsync, isError, error, isLoading } = domainId
|
||||||
? api.domain.update.useMutation()
|
? api.domain.update.useMutation()
|
||||||
: api.domain.create.useMutation();
|
: api.domain.create.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: generateDomain, isPending: isLoadingGenerate } =
|
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
|
||||||
api.domain.generateDomain.useMutation();
|
api.domain.generateDomain.useMutation();
|
||||||
|
|
||||||
const { data: canGenerateTraefikMeDomains } =
|
const { data: canGenerateTraefikMeDomains } =
|
||||||
@@ -208,24 +196,20 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
internalPath: undefined,
|
internalPath: undefined,
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
port: undefined,
|
port: undefined,
|
||||||
useCustomEntrypoint: false,
|
|
||||||
customEntrypoint: undefined,
|
|
||||||
https: false,
|
https: false,
|
||||||
certificateType: undefined,
|
certificateType: undefined,
|
||||||
customCertResolver: undefined,
|
customCertResolver: undefined,
|
||||||
serviceName: undefined,
|
serviceName: undefined,
|
||||||
domainType: type,
|
domainType: type,
|
||||||
middlewares: [],
|
|
||||||
},
|
},
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
const certificateType = form.watch("certificateType");
|
const certificateType = form.watch("certificateType");
|
||||||
const useCustomEntrypoint = form.watch("useCustomEntrypoint");
|
|
||||||
const https = form.watch("https");
|
const https = form.watch("https");
|
||||||
const domainType = form.watch("domainType");
|
const domainType = form.watch("domainType");
|
||||||
const host = form.watch("host");
|
const host = form.watch("host");
|
||||||
const isTraefikMeDomain = host?.includes("sslip.io") || false;
|
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -236,13 +220,10 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
internalPath: data?.internalPath || undefined,
|
internalPath: data?.internalPath || undefined,
|
||||||
stripPath: data?.stripPath || false,
|
stripPath: data?.stripPath || false,
|
||||||
port: data?.port || undefined,
|
port: data?.port || undefined,
|
||||||
useCustomEntrypoint: !!data.customEntrypoint,
|
|
||||||
customEntrypoint: data.customEntrypoint || undefined,
|
|
||||||
certificateType: data?.certificateType || undefined,
|
certificateType: data?.certificateType || undefined,
|
||||||
customCertResolver: data?.customCertResolver || undefined,
|
customCertResolver: data?.customCertResolver || undefined,
|
||||||
serviceName: data?.serviceName || undefined,
|
serviceName: data?.serviceName || undefined,
|
||||||
domainType: data?.domainType || type,
|
domainType: data?.domainType || type,
|
||||||
middlewares: data?.middlewares || [],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,16 +234,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
internalPath: undefined,
|
internalPath: undefined,
|
||||||
stripPath: false,
|
stripPath: false,
|
||||||
port: undefined,
|
port: undefined,
|
||||||
useCustomEntrypoint: false,
|
|
||||||
customEntrypoint: undefined,
|
|
||||||
https: false,
|
https: false,
|
||||||
certificateType: undefined,
|
certificateType: undefined,
|
||||||
customCertResolver: undefined,
|
customCertResolver: undefined,
|
||||||
domainType: type,
|
domainType: type,
|
||||||
middlewares: [],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, data, isPending, domainId]);
|
}, [form, data, isLoading, domainId]);
|
||||||
|
|
||||||
// Separate effect for handling custom cert resolver validation
|
// Separate effect for handling custom cert resolver validation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -290,7 +268,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
composeId: id,
|
composeId: id,
|
||||||
}),
|
}),
|
||||||
...data,
|
...data,
|
||||||
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
|
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success(dictionary.success);
|
toast.success(dictionary.success);
|
||||||
@@ -513,7 +490,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
{!canGenerateTraefikMeDomains &&
|
{!canGenerateTraefikMeDomains &&
|
||||||
field.value.includes("sslip.io") && (
|
field.value.includes("traefik.me") && (
|
||||||
<AlertBlock type="warning">
|
<AlertBlock type="warning">
|
||||||
You need to set an IP address in your{" "}
|
You need to set an IP address in your{" "}
|
||||||
<Link
|
<Link
|
||||||
@@ -524,12 +501,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||||
: "Web Server -> Server -> Update Server IP"}
|
: "Web Server -> Server -> Update Server IP"}
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
to make your sslip.io domain work.
|
to make your traefik.me domain work.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
{isTraefikMeDomain && (
|
{isTraefikMeDomain && (
|
||||||
<AlertBlock type="info">
|
<AlertBlock type="info">
|
||||||
<strong>Note:</strong> sslip.io is a public HTTP
|
<strong>Note:</strong> traefik.me is a public HTTP
|
||||||
service and does not support SSL/HTTPS. HTTPS and
|
service and does not support SSL/HTTPS. HTTPS and
|
||||||
certificate options will not have any effect.
|
certificate options will not have any effect.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
@@ -567,7 +544,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
sideOffset={5}
|
sideOffset={5}
|
||||||
className="max-w-[10rem]"
|
className="max-w-[10rem]"
|
||||||
>
|
>
|
||||||
<p>Generate sslip.io domain</p>
|
<p>Generate traefik.me domain</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -658,55 +635,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="useCustomEntrypoint"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>Custom Entrypoint</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Use custom entrypoint for domain
|
|
||||||
<br />
|
|
||||||
"web" and/or "websecure" is used by default.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
field.onChange(checked);
|
|
||||||
if (!checked) {
|
|
||||||
form.setValue("customEntrypoint", undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{useCustomEntrypoint && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="customEntrypoint"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="w-full">
|
|
||||||
<FormLabel>Entrypoint Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="Enter entrypoint name manually"
|
|
||||||
{...field}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="https"
|
name="https"
|
||||||
@@ -797,94 +725,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="middlewares"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FormLabel>Middlewares</FormLabel>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger type="button">
|
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
|
||||||
?
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="max-w-[300px]">
|
|
||||||
<p>
|
|
||||||
Add Traefik middleware references. Middlewares
|
|
||||||
must be defined in your Traefik configuration.
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
|
||||||
{field.value?.map((name, index) => (
|
|
||||||
<Badge key={index} variant="secondary">
|
|
||||||
{name}
|
|
||||||
<X
|
|
||||||
className="ml-1 size-3 cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
const newMiddlewares = [...(field.value || [])];
|
|
||||||
newMiddlewares.splice(index, 1);
|
|
||||||
form.setValue("middlewares", newMiddlewares);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
placeholder="e.g., rate-limit@file, auth@file"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
const input = e.currentTarget;
|
|
||||||
const value = input.value.trim();
|
|
||||||
if (value && !field.value?.includes(value)) {
|
|
||||||
form.setValue("middlewares", [
|
|
||||||
...(field.value || []),
|
|
||||||
value,
|
|
||||||
]);
|
|
||||||
input.value = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
const input = document.querySelector(
|
|
||||||
'input[placeholder="e.g., rate-limit@file, auth@file"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
const value = input.value.trim();
|
|
||||||
if (value && !field.value?.includes(value)) {
|
|
||||||
form.setValue("middlewares", [
|
|
||||||
...(field.value || []),
|
|
||||||
value,
|
|
||||||
]);
|
|
||||||
input.value = "";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button isLoading={isPending} form="hook-form" type="submit">
|
<Button isLoading={isLoading} form="hook-form" type="submit">
|
||||||
{dictionary.submit}
|
{dictionary.submit}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
import { ShieldCheck } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
domainId: string;
|
|
||||||
applicationId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HandleForwardAuth = ({ domainId, applicationId }: Props) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const { data: haveValidLicense } =
|
|
||||||
api.licenseKey.haveValidLicenseKey.useQuery();
|
|
||||||
|
|
||||||
const utils = api.useUtils();
|
|
||||||
|
|
||||||
const { data: status } = api.forwardAuth.status.useQuery(
|
|
||||||
{ domainId },
|
|
||||||
{ enabled: isOpen },
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync: enable, isPending: isEnabling } =
|
|
||||||
api.forwardAuth.enable.useMutation();
|
|
||||||
const { mutateAsync: disable, isPending: isDisabling } =
|
|
||||||
api.forwardAuth.disable.useMutation();
|
|
||||||
|
|
||||||
if (!haveValidLicense) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isEnabled = !!status?.enabled;
|
|
||||||
const isPending = isEnabling || isDisabling;
|
|
||||||
|
|
||||||
const refresh = async () => {
|
|
||||||
await utils.forwardAuth.status.invalidate({ domainId });
|
|
||||||
await utils.domain.byApplicationId.invalidate({ applicationId });
|
|
||||||
await utils.application.readTraefikConfig.invalidate({ applicationId });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggle = async (next: boolean) => {
|
|
||||||
try {
|
|
||||||
if (next) {
|
|
||||||
await enable({ domainId });
|
|
||||||
toast.success("SSO authentication enabled for this domain");
|
|
||||||
} else {
|
|
||||||
await disable({ domainId });
|
|
||||||
toast.success("SSO authentication disabled for this domain");
|
|
||||||
}
|
|
||||||
await refresh();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Error updating SSO authentication",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-emerald-500/10"
|
|
||||||
title="SSO authentication"
|
|
||||||
>
|
|
||||||
<ShieldCheck
|
|
||||||
className={`size-4 ${
|
|
||||||
isEnabled
|
|
||||||
? "text-emerald-500"
|
|
||||||
: "text-primary group-hover:text-emerald-500"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>SSO Authentication</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Require visitors to authenticate against your identity provider
|
|
||||||
before reaching this application.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<AlertBlock type="warning">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">Requirements</span>
|
|
||||||
<ol className="list-decimal pl-4 text-sm">
|
|
||||||
<li>
|
|
||||||
The authentication proxy container must be deployed and running
|
|
||||||
on this app's server. Configure it under{" "}
|
|
||||||
<span className="font-medium">
|
|
||||||
Settings → SSO → Application Authentication
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
This domain must share the same base domain as the
|
|
||||||
authentication domain (e.g. <code>app.acme.com</code> and{" "}
|
|
||||||
<code>auth.acme.com</code>).
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</AlertBlock>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4 mt-2">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
Protect this domain with SSO
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{isEnabled
|
|
||||||
? "Visitors must log in via your identity provider."
|
|
||||||
: "The domain is publicly accessible."}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={isEnabled}
|
|
||||||
disabled={isPending}
|
|
||||||
onCheckedChange={handleToggle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setIsOpen(false)}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,22 +1,8 @@
|
|||||||
import {
|
|
||||||
type ColumnFiltersState,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
getFilteredRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
type SortingState,
|
|
||||||
useReactTable,
|
|
||||||
type VisibilityState,
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
LayoutGrid,
|
|
||||||
LayoutList,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
PenBoxIcon,
|
PenBoxIcon,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -37,21 +23,6 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -59,10 +30,8 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { createColumns } from "./columns";
|
|
||||||
import { DnsHelperModal } from "./dns-helper-modal";
|
import { DnsHelperModal } from "./dns-helper-modal";
|
||||||
import { AddDomain } from "./handle-domain";
|
import { AddDomain } from "./handle-domain";
|
||||||
import { HandleForwardAuth } from "./handle-forward-auth";
|
|
||||||
|
|
||||||
export type ValidationState = {
|
export type ValidationState = {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -81,9 +50,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowDomains = ({ id, type }: Props) => {
|
export const ShowDomains = ({ id, type }: Props) => {
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
|
||||||
const canCreateDomain = permissions?.domain.create ?? false;
|
|
||||||
const canDeleteDomain = permissions?.domain.delete ?? false;
|
|
||||||
const { data: application } =
|
const { data: application } =
|
||||||
type === "application"
|
type === "application"
|
||||||
? api.application.one.useQuery(
|
? api.application.one.useQuery(
|
||||||
@@ -105,19 +71,6 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
const [validationStates, setValidationStates] = useState<ValidationStates>(
|
const [validationStates, setValidationStates] = useState<ValidationStates>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "table">(() => {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
return (
|
|
||||||
(localStorage.getItem("domains-view-mode") as "grid" | "table") ??
|
|
||||||
"grid"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return "grid";
|
|
||||||
});
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
|
||||||
const [rowSelection, setRowSelection] = useState({});
|
|
||||||
const { data: ip } = api.settings.getIp.useQuery();
|
const { data: ip } = api.settings.getIp.useQuery();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -144,19 +97,9 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
|
|
||||||
const { mutateAsync: validateDomain } =
|
const { mutateAsync: validateDomain } =
|
||||||
api.domain.validateDomain.useMutation();
|
api.domain.validateDomain.useMutation();
|
||||||
const { mutateAsync: deleteDomain, isPending: isRemoving } =
|
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
|
||||||
api.domain.delete.useMutation();
|
api.domain.delete.useMutation();
|
||||||
|
|
||||||
const handleDeleteDomain = async (domainId: string) => {
|
|
||||||
try {
|
|
||||||
await deleteDomain({ domainId });
|
|
||||||
refetch();
|
|
||||||
toast.success("Domain deleted successfully");
|
|
||||||
} catch {
|
|
||||||
toast.error("Error deleting domain");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleValidateDomain = async (host: string) => {
|
const handleValidateDomain = async (host: string) => {
|
||||||
setValidationStates((prev) => ({
|
setValidationStates((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -194,37 +137,6 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = createColumns({
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
validationStates,
|
|
||||||
handleValidateDomain,
|
|
||||||
handleDeleteDomain,
|
|
||||||
isDeleting: isRemoving,
|
|
||||||
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
|
|
||||||
canCreateDomain,
|
|
||||||
canDeleteDomain,
|
|
||||||
});
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: data ?? [],
|
|
||||||
columns,
|
|
||||||
onSortingChange: setSorting,
|
|
||||||
onColumnFiltersChange: setColumnFilters,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
|
||||||
onRowSelectionChange: setRowSelection,
|
|
||||||
state: {
|
|
||||||
sorting,
|
|
||||||
columnFilters,
|
|
||||||
columnVisibility,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
@@ -236,32 +148,13 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row gap-2 flex-wrap">
|
<div className="flex flex-row gap-4 flex-wrap">
|
||||||
{data && data?.length > 0 && (
|
{data && data?.length > 0 && (
|
||||||
<>
|
<AddDomain id={id} type={type}>
|
||||||
<Button
|
<Button>
|
||||||
variant="outline"
|
<GlobeIcon className="size-4" /> Add Domain
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
|
||||||
const next = viewMode === "grid" ? "table" : "grid";
|
|
||||||
localStorage.setItem("domains-view-mode", next);
|
|
||||||
setViewMode(next);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{viewMode === "grid" ? (
|
|
||||||
<LayoutList className="size-4" />
|
|
||||||
) : (
|
|
||||||
<LayoutGrid className="size-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
{canCreateDomain && (
|
</AddDomain>
|
||||||
<AddDomain id={id} type={type}>
|
|
||||||
<Button>
|
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
|
||||||
</Button>
|
|
||||||
</AddDomain>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -280,131 +173,13 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
To access the application it is required to set at least 1
|
To access the application it is required to set at least 1
|
||||||
domain
|
domain
|
||||||
</span>
|
</span>
|
||||||
{canCreateDomain && (
|
<div className="flex flex-row gap-4 flex-wrap">
|
||||||
<div className="flex flex-row gap-4 flex-wrap">
|
<AddDomain id={id} type={type}>
|
||||||
<AddDomain id={id} type={type}>
|
<Button>
|
||||||
<Button>
|
<GlobeIcon className="size-4" /> Add Domain
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
</Button>
|
||||||
</Button>
|
</AddDomain>
|
||||||
</AddDomain>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : viewMode === "table" ? (
|
|
||||||
<div className="flex flex-col gap-4 w-full">
|
|
||||||
<div className="flex items-center gap-2 max-sm:flex-wrap">
|
|
||||||
<Input
|
|
||||||
placeholder="Filter by host..."
|
|
||||||
value={
|
|
||||||
(table.getColumn("host")?.getFilterValue() as string) ?? ""
|
|
||||||
}
|
|
||||||
onChange={(event) =>
|
|
||||||
table.getColumn("host")?.setFilterValue(event.target.value)
|
|
||||||
}
|
|
||||||
className="md:max-w-sm"
|
|
||||||
/>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="sm:ml-auto max-sm:w-full"
|
|
||||||
>
|
|
||||||
Columns <ChevronDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{table
|
|
||||||
.getAllColumns()
|
|
||||||
.filter((column) => column.getCanHide())
|
|
||||||
.map((column) => {
|
|
||||||
return (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={column.id}
|
|
||||||
className="capitalize"
|
|
||||||
checked={column.getIsVisible()}
|
|
||||||
onCheckedChange={(value) =>
|
|
||||||
column.toggleVisibility(!!value)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{column.id}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<TableRow key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => {
|
|
||||||
return (
|
|
||||||
<TableHead key={header.id}>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext(),
|
|
||||||
)}
|
|
||||||
</TableHead>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{table?.getRowModel()?.rows?.length ? (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
data-state={row.getIsSelected() && "selected"}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell key={cell.id}>
|
|
||||||
{flexRender(
|
|
||||||
cell.column.columnDef.cell,
|
|
||||||
cell.getContext(),
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={columns.length}
|
|
||||||
className="h-24 text-center"
|
|
||||||
>
|
|
||||||
No results.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
{data && data?.length > 0 && (
|
|
||||||
<div className="flex items-center justify-end space-x-2 py-4">
|
|
||||||
<div className="space-x-2 flex flex-wrap">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => table.previousPage()}
|
|
||||||
disabled={!table.getCanPreviousPage()}
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => table.nextPage()}
|
|
||||||
disabled={!table.getCanNextPage()}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
|
||||||
@@ -426,7 +201,7 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{!item.host.includes("sslip.io") && (
|
{!item.host.includes("traefik.me") && (
|
||||||
<DnsHelperModal
|
<DnsHelperModal
|
||||||
domain={{
|
domain={{
|
||||||
host: item.host,
|
host: item.host,
|
||||||
@@ -439,57 +214,47 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{canCreateDomain && (
|
<AddDomain
|
||||||
<AddDomain
|
id={id}
|
||||||
id={id}
|
type={type}
|
||||||
type={type}
|
domainId={item.domainId}
|
||||||
domainId={item.domainId}
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-blue-500/10"
|
||||||
>
|
>
|
||||||
<Button
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="icon"
|
</AddDomain>
|
||||||
className="group hover:bg-blue-500/10"
|
<DialogAction
|
||||||
>
|
title="Delete Domain"
|
||||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
description="Are you sure you want to delete this domain?"
|
||||||
</Button>
|
type="destructive"
|
||||||
</AddDomain>
|
onClick={async () => {
|
||||||
)}
|
await deleteDomain({
|
||||||
{canCreateDomain && type === "application" && (
|
domainId: item.domainId,
|
||||||
<HandleForwardAuth
|
})
|
||||||
domainId={item.domainId}
|
.then((_data) => {
|
||||||
applicationId={id}
|
refetch();
|
||||||
/>
|
toast.success(
|
||||||
)}
|
"Domain deleted successfully",
|
||||||
{canDeleteDomain && (
|
);
|
||||||
<DialogAction
|
|
||||||
title="Delete Domain"
|
|
||||||
description="Are you sure you want to delete this domain?"
|
|
||||||
type="destructive"
|
|
||||||
onClick={async () => {
|
|
||||||
await deleteDomain({
|
|
||||||
domainId: item.domainId,
|
|
||||||
})
|
})
|
||||||
.then((_data) => {
|
.catch(() => {
|
||||||
refetch();
|
toast.error("Error deleting domain");
|
||||||
toast.success(
|
});
|
||||||
"Domain deleted successfully",
|
}}
|
||||||
);
|
>
|
||||||
})
|
<Button
|
||||||
.catch(() => {
|
variant="ghost"
|
||||||
toast.error("Error deleting domain");
|
size="icon"
|
||||||
});
|
className="group hover:bg-red-500/10"
|
||||||
}}
|
isLoading={isRemoving}
|
||||||
>
|
>
|
||||||
<Button
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="icon"
|
</DialogAction>
|
||||||
className="group hover:bg-red-500/10"
|
|
||||||
isLoading={isRemoving}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full break-all">
|
<div className="w-full break-all">
|
||||||
@@ -567,22 +332,6 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.middlewares?.map((middleware, index) => (
|
|
||||||
<TooltipProvider key={`${middleware}-${index}`}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
<InfoIcon className="size-3 mr-1" />
|
|
||||||
Middleware: {middleware}
|
|
||||||
</Badge>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Traefik middleware reference</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
||||||
import { type CSSProperties, useEffect, useState } from "react";
|
import { type CSSProperties, useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -36,19 +36,16 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowEnvironment = ({ id, type }: Props) => {
|
export const ShowEnvironment = ({ id, type }: Props) => {
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
|
||||||
const canWrite = permissions?.envVars.write ?? false;
|
|
||||||
const queryMap = {
|
const queryMap = {
|
||||||
compose: () =>
|
|
||||||
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
|
||||||
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
|
||||||
mariadb: () =>
|
|
||||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
|
||||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
|
||||||
postgres: () =>
|
postgres: () =>
|
||||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
compose: () =>
|
||||||
|
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -56,17 +53,16 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
||||||
|
|
||||||
const mutationMap = {
|
const mutationMap = {
|
||||||
compose: () => api.compose.saveEnvironment.useMutation(),
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
libsql: () => api.libsql.saveEnvironment.useMutation(),
|
redis: () => api.redis.update.useMutation(),
|
||||||
mariadb: () => api.mariadb.saveEnvironment.useMutation(),
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
mongo: () => api.mongo.saveEnvironment.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
mysql: () => api.mysql.saveEnvironment.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
postgres: () => api.postgres.saveEnvironment.useMutation(),
|
compose: () => api.compose.update.useMutation(),
|
||||||
redis: () => api.redis.saveEnvironment.useMutation(),
|
|
||||||
};
|
};
|
||||||
const { mutateAsync, isPending } = mutationMap[type]
|
const { mutateAsync, isLoading } = mutationMap[type]
|
||||||
? mutationMap[type]()
|
? mutationMap[type]()
|
||||||
: api.mongo.saveEnvironment.useMutation();
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
const form = useForm<EnvironmentSchema>({
|
const form = useForm<EnvironmentSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -89,13 +85,12 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
|
|
||||||
const onSubmit = async (formData: EnvironmentSchema) => {
|
const onSubmit = async (formData: EnvironmentSchema) => {
|
||||||
mutateAsync({
|
mutateAsync({
|
||||||
composeId: id || "",
|
|
||||||
libsqlId: id || "",
|
|
||||||
mariadbId: id || "",
|
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
mysqlId: id || "",
|
|
||||||
postgresId: id || "",
|
postgresId: id || "",
|
||||||
redisId: id || "",
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
composeId: id || "",
|
||||||
env: formData.environment,
|
env: formData.environment,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -116,7 +111,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
|
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
@@ -126,7 +121,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [form, onSubmit, isPending]);
|
}, [form, onSubmit, isLoading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
@@ -190,27 +185,25 @@ PORT=3000
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{canWrite && (
|
<div className="flex flex-row justify-end gap-2">
|
||||||
<div className="flex flex-row justify-end gap-2">
|
{hasChanges && (
|
||||||
{hasChanges && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleCancel}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
isLoading={isPending}
|
type="button"
|
||||||
className="w-fit"
|
variant="outline"
|
||||||
type="submit"
|
onClick={handleCancel}
|
||||||
disabled={!hasChanges}
|
|
||||||
>
|
>
|
||||||
Save
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
)}
|
<Button
|
||||||
|
isLoading={isLoading}
|
||||||
|
className="w-fit"
|
||||||
|
type="submit"
|
||||||
|
disabled={!hasChanges}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -31,9 +31,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowEnvironment = ({ applicationId }: Props) => {
|
export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
const { mutateAsync, isLoading } =
|
||||||
const canWrite = permissions?.envVars.write ?? false;
|
|
||||||
const { mutateAsync, isPending } =
|
|
||||||
api.application.saveEnvironment.useMutation();
|
api.application.saveEnvironment.useMutation();
|
||||||
|
|
||||||
const { data, refetch } = api.application.one.useQuery(
|
const { data, refetch } = api.application.one.useQuery(
|
||||||
@@ -106,7 +104,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
|
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
@@ -116,7 +114,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [form, onSubmit, isPending]);
|
}, [form, onSubmit, isLoading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background px-6 pb-6">
|
<Card className="bg-background px-6 pb-6">
|
||||||
@@ -203,30 +201,27 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
<Switch
|
<Switch
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
disabled={!canWrite}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{canWrite && (
|
<div className="flex flex-row justify-end gap-2">
|
||||||
<div className="flex flex-row justify-end gap-2">
|
{hasChanges && (
|
||||||
{hasChanges && (
|
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
Cancel
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
isLoading={isPending}
|
|
||||||
className="w-fit"
|
|
||||||
type="submit"
|
|
||||||
disabled={!hasChanges}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
)}
|
<Button
|
||||||
|
isLoading={isLoading}
|
||||||
|
className="w-fit"
|
||||||
|
type="submit"
|
||||||
|
disabled={!hasChanges}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -58,10 +57,7 @@ const BitbucketProviderSchema = z.object({
|
|||||||
slug: z.string().optional(),
|
slug: z.string().optional(),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch is required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().optional(),
|
enableSubmodules: z.boolean().optional(),
|
||||||
@@ -78,10 +74,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
api.bitbucket.bitbucketProviders.useQuery();
|
api.bitbucket.bitbucketProviders.useQuery();
|
||||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||||
|
|
||||||
const { mutateAsync, isPending: isSavingBitbucketProvider } =
|
const { mutateAsync, isLoading: isSavingBitbucketProvider } =
|
||||||
api.application.saveBitbucketProvider.useMutation();
|
api.application.saveBitbucketProvider.useMutation();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm<BitbucketProvider>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
buildPath: "/",
|
buildPath: "/",
|
||||||
repository: {
|
repository: {
|
||||||
@@ -249,13 +245,13 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!field.value.owner
|
{isLoadingRepositories
|
||||||
? "Select repository"
|
? "Loading...."
|
||||||
: isLoadingRepositories
|
: field.value.owner
|
||||||
? "Loading...."
|
? repositories?.find(
|
||||||
: (repositories?.find(
|
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name ?? "Select repository")}
|
)?.name
|
||||||
|
: "Select repository"}
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -267,15 +263,11 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{!bitbucketId ? (
|
{isLoadingRepositories && (
|
||||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
Select a Bitbucket account first
|
|
||||||
</span>
|
|
||||||
) : isLoadingRepositories ? (
|
|
||||||
<span className="py-6 text-center text-sm">
|
<span className="py-6 text-center text-sm">
|
||||||
Loading Repositories....
|
Loading Repositories....
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
)}
|
||||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
@@ -337,7 +329,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === "pending" && fetchStatus === "fetching"
|
{status === "loading" && fetchStatus === "fetching"
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: field.value
|
: field.value
|
||||||
? branches?.find(
|
? branches?.find(
|
||||||
@@ -354,7 +346,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search branch..."
|
placeholder="Search branch..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{status === "pending" && fetchStatus === "fetching" && (
|
{status === "loading" && fetchStatus === "fetching" && (
|
||||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||||
Loading Branches....
|
Loading Branches....
|
||||||
</span>
|
</span>
|
||||||
@@ -420,8 +412,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger>
|
||||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||||
|
?
|
||||||
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { TrashIcon } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -24,10 +24,10 @@ interface Props {
|
|||||||
export const SaveDragNDrop = ({ applicationId }: Props) => {
|
export const SaveDragNDrop = ({ applicationId }: Props) => {
|
||||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||||
|
|
||||||
const { mutateAsync, isPending } =
|
const { mutateAsync, isLoading } =
|
||||||
api.application.dropDeployment.useMutation();
|
api.application.dropDeployment.useMutation();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm<UploadFile>({
|
||||||
defaultValues: {},
|
defaultValues: {},
|
||||||
resolver: zodResolver(uploadFileSchema),
|
resolver: zodResolver(uploadFileSchema),
|
||||||
});
|
});
|
||||||
@@ -129,8 +129,8 @@ export const SaveDragNDrop = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-fit"
|
className="w-fit"
|
||||||
isLoading={isPending}
|
isLoading={isLoading}
|
||||||
disabled={!zip || isPending}
|
disabled={!zip || isLoading}
|
||||||
>
|
>
|
||||||
Deploy{" "}
|
Deploy{" "}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||||
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -42,10 +41,7 @@ const GitProviderSchema = z.object({
|
|||||||
repositoryURL: z.string().min(1, {
|
repositoryURL: z.string().min(1, {
|
||||||
message: "Repository URL is required",
|
message: "Repository URL is required",
|
||||||
}),
|
}),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
sshKey: z.string().optional(),
|
sshKey: z.string().optional(),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
@@ -59,13 +55,13 @@ interface Props {
|
|||||||
|
|
||||||
export const SaveGitProvider = ({ applicationId }: Props) => {
|
export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||||
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
|
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { mutateAsync, isPending } =
|
const { mutateAsync, isLoading } =
|
||||||
api.application.saveGitProvider.useMutation();
|
api.application.saveGitProvider.useMutation();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm<GitProvider>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
branch: "",
|
branch: "",
|
||||||
buildPath: "/",
|
buildPath: "/",
|
||||||
@@ -111,103 +107,110 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 items-start">
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
<FormField
|
className="flex flex-col gap-4"
|
||||||
control={form.control}
|
>
|
||||||
name="repositoryURL"
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
render={({ field }) => (
|
<div className="flex items-end col-span-2 gap-4">
|
||||||
<FormItem className="col-span-2 lg:col-span-3">
|
<div className="grow">
|
||||||
<div className="flex items-center justify-between h-5">
|
<FormField
|
||||||
<FormLabel>Repository URL</FormLabel>
|
control={form.control}
|
||||||
{field.value?.startsWith("https://") && (
|
name="repositoryURL"
|
||||||
<Link
|
render={({ field }) => (
|
||||||
href={field.value}
|
<FormItem>
|
||||||
target="_blank"
|
<div className="flex items-center justify-between">
|
||||||
rel="noopener noreferrer"
|
<FormLabel>Repository URL</FormLabel>
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
{field.value?.startsWith("https://") && (
|
||||||
>
|
<Link
|
||||||
<GitIcon className="h-4 w-4" />
|
href={field.value}
|
||||||
<span>View Repository</span>
|
target="_blank"
|
||||||
</Link>
|
rel="noopener noreferrer"
|
||||||
)}
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
</div>
|
>
|
||||||
<FormControl>
|
<GitIcon className="h-4 w-4" />
|
||||||
<Input placeholder="Repository URL" {...field} />
|
<span>View Repository</span>
|
||||||
</FormControl>
|
</Link>
|
||||||
<FormMessage />
|
)}
|
||||||
</FormItem>
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Repository URL" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{sshKeys && sshKeys.length > 0 ? (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="sshKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="basis-40">
|
||||||
|
<FormLabel className="w-full inline-flex justify-between">
|
||||||
|
SSH Key
|
||||||
|
<LockIcon className="size-4 text-muted-foreground" />
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
key={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a key" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{sshKeys?.map((sshKey) => (
|
||||||
|
<SelectItem
|
||||||
|
key={sshKey.sshKeyId}
|
||||||
|
value={sshKey.sshKeyId}
|
||||||
|
>
|
||||||
|
{sshKey.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => router.push("/dashboard/settings/ssh-keys")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<KeyRoundIcon className="size-4" /> Add SSH Key
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
/>
|
</div>
|
||||||
{sshKeys && sshKeys.length > 0 ? (
|
<div className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="sshKey"
|
name="branch"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="col-span-2 lg:col-span-1">
|
<FormItem>
|
||||||
<FormLabel className="w-full inline-flex justify-between">
|
<FormLabel>Branch</FormLabel>
|
||||||
SSH Key
|
|
||||||
<LockIcon className="size-4 text-muted-foreground" />
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Input placeholder="Branch" {...field} />
|
||||||
key={field.value}
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
value={field.value}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a key" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{sshKeys?.map((sshKey) => (
|
|
||||||
<SelectItem
|
|
||||||
key={sshKey.sshKeyId}
|
|
||||||
value={sshKey.sshKeyId}
|
|
||||||
>
|
|
||||||
{sshKey.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
<SelectItem value="none">None</SelectItem>
|
|
||||||
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => router.push("/dashboard/settings/ssh-keys")}
|
|
||||||
type="button"
|
|
||||||
className="col-span-2 lg:col-span-1 lg:mt-7"
|
|
||||||
>
|
|
||||||
<KeyRoundIcon className="size-4" /> Add SSH Key
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="branch"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="col-span-2">
|
|
||||||
<FormLabel>Branch</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Branch" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="buildPath"
|
name="buildPath"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="col-span-2">
|
<FormItem>
|
||||||
<FormLabel>Build Path</FormLabel>
|
<FormLabel>Build Path</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="/" {...field} />
|
<Input placeholder="/" {...field} />
|
||||||
@@ -220,13 +223,15 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="watchPaths"
|
name="watchPaths"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="col-span-2 lg:col-span-4">
|
<FormItem className="md:col-span-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger>
|
||||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||||
|
?
|
||||||
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-[300px]">
|
<TooltipContent className="max-w-[300px]">
|
||||||
<p>
|
<p>
|
||||||
@@ -312,7 +317,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row justify-end">
|
<div className="flex flex-row justify-end">
|
||||||
<Button type="submit" className="w-fit" isLoading={isPending}>
|
<Button type="submit" className="w-fit" isLoading={isLoading}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -73,10 +72,7 @@ const GiteaProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch is required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
giteaId: z.string().min(1, "Gitea Provider is required"),
|
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||||
watchPaths: z.array(z.string()).default([]),
|
watchPaths: z.array(z.string()).default([]),
|
||||||
enableSubmodules: z.boolean().optional(),
|
enableSubmodules: z.boolean().optional(),
|
||||||
@@ -92,10 +88,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
|
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
|
||||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||||
|
|
||||||
const { mutateAsync, isPending: isSavingGiteaProvider } =
|
const { mutateAsync, isLoading: isSavingGiteaProvider } =
|
||||||
api.application.saveGiteaProvider.useMutation();
|
api.application.saveGiteaProvider.useMutation();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm<GiteaProvider>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
buildPath: "/",
|
buildPath: "/",
|
||||||
repository: {
|
repository: {
|
||||||
@@ -262,14 +258,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!field.value.owner
|
{isLoadingRepositories
|
||||||
? "Select repository"
|
? "Loading...."
|
||||||
: isLoadingRepositories
|
: field.value.owner
|
||||||
? "Loading...."
|
? repositories?.find(
|
||||||
: (repositories?.find(
|
|
||||||
(repo: GiteaRepository) =>
|
(repo: GiteaRepository) =>
|
||||||
repo.name === field.value.repo,
|
repo.name === field.value.repo,
|
||||||
)?.name ?? "Select repository")}
|
)?.name
|
||||||
|
: "Select repository"}
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -281,15 +277,11 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{!giteaId ? (
|
{isLoadingRepositories && (
|
||||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
Select a Gitea account first
|
|
||||||
</span>
|
|
||||||
) : isLoadingRepositories ? (
|
|
||||||
<span className="py-6 text-center text-sm">
|
<span className="py-6 text-center text-sm">
|
||||||
Loading Repositories....
|
Loading Repositories....
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
)}
|
||||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
@@ -357,7 +349,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === "pending" && fetchStatus === "fetching"
|
{status === "loading" && fetchStatus === "fetching"
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: field.value
|
: field.value
|
||||||
? branches?.find(
|
? branches?.find(
|
||||||
@@ -375,7 +367,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search branch..."
|
placeholder="Search branch..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{status === "pending" && fetchStatus === "fetching" && (
|
{status === "loading" && fetchStatus === "fetching" && (
|
||||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||||
Loading Branches....
|
Loading Branches....
|
||||||
</span>
|
</span>
|
||||||
@@ -467,7 +459,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
<X
|
<X
|
||||||
className="size-3 cursor-pointer hover:text-destructive"
|
className="size-3 cursor-pointer hover:text-destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newPaths = [...(field.value || [])];
|
const newPaths = [...field.value];
|
||||||
newPaths.splice(index, 1);
|
newPaths.splice(index, 1);
|
||||||
field.onChange(newPaths);
|
field.onChange(newPaths);
|
||||||
}}
|
}}
|
||||||
@@ -485,7 +477,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
const input = e.currentTarget;
|
const input = e.currentTarget;
|
||||||
const path = input.value.trim();
|
const path = input.value.trim();
|
||||||
if (path) {
|
if (path) {
|
||||||
field.onChange([...(field.value || []), path]);
|
field.onChange([...field.value, path]);
|
||||||
input.value = "";
|
input.value = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -502,7 +494,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
const path = input.value.trim();
|
const path = input.value.trim();
|
||||||
if (path) {
|
if (path) {
|
||||||
field.onChange([...(field.value || []), path]);
|
field.onChange([...field.value, path]);
|
||||||
input.value = "";
|
input.value = "";
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -56,10 +55,7 @@ const GithubProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch is required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
githubId: z.string().min(1, "Github Provider is required"),
|
githubId: z.string().min(1, "Github Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
triggerType: z.enum(["push", "tag"]).default("push"),
|
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||||
@@ -76,10 +72,10 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
const { data: githubProviders } = api.github.githubProviders.useQuery();
|
const { data: githubProviders } = api.github.githubProviders.useQuery();
|
||||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||||
|
|
||||||
const { mutateAsync, isPending: isSavingGithubProvider } =
|
const { mutateAsync, isLoading: isSavingGithubProvider } =
|
||||||
api.application.saveGithubProvider.useMutation();
|
api.application.saveGithubProvider.useMutation();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm<GithubProvider>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
buildPath: "/",
|
buildPath: "/",
|
||||||
repository: {
|
repository: {
|
||||||
@@ -98,7 +94,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
const githubId = form.watch("githubId");
|
const githubId = form.watch("githubId");
|
||||||
const triggerType = form.watch("triggerType");
|
const triggerType = form.watch("triggerType");
|
||||||
|
|
||||||
const { data: repositories, isPending: isLoadingRepositories } =
|
const { data: repositories, isLoading: isLoadingRepositories } =
|
||||||
api.github.getGithubRepositories.useQuery(
|
api.github.getGithubRepositories.useQuery(
|
||||||
{
|
{
|
||||||
githubId,
|
githubId,
|
||||||
@@ -237,13 +233,13 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!field.value.owner
|
{isLoadingRepositories
|
||||||
? "Select repository"
|
? "Loading...."
|
||||||
: isLoadingRepositories
|
: field.value.owner
|
||||||
? "Loading...."
|
? repositories?.find(
|
||||||
: (repositories?.find(
|
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name ?? "Select repository")}
|
)?.name
|
||||||
|
: "Select repository"}
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -255,15 +251,11 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{!githubId ? (
|
{isLoadingRepositories && (
|
||||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
Select a GitHub account first
|
|
||||||
</span>
|
|
||||||
) : isLoadingRepositories ? (
|
|
||||||
<span className="py-6 text-center text-sm">
|
<span className="py-6 text-center text-sm">
|
||||||
Loading Repositories....
|
Loading Repositories....
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
)}
|
||||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
@@ -324,7 +316,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === "pending" && fetchStatus === "fetching"
|
{status === "loading" && fetchStatus === "fetching"
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: field.value
|
: field.value
|
||||||
? branches?.find(
|
? branches?.find(
|
||||||
@@ -341,7 +333,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search branch..."
|
placeholder="Search branch..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{status === "pending" && fetchStatus === "fetching" && (
|
{status === "loading" && fetchStatus === "fetching" && (
|
||||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||||
Loading Branches....
|
Loading Branches....
|
||||||
</span>
|
</span>
|
||||||
@@ -463,7 +455,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
<div className="flex flex-wrap gap-2 mb-2">
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
{field.value?.map((path, index) => (
|
{field.value?.map((path, index) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={`${path}-${index}`}
|
key={index}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
@@ -59,10 +58,7 @@ const GitlabProviderSchema = z.object({
|
|||||||
id: z.number().nullable(),
|
id: z.number().nullable(),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z
|
branch: z.string().min(1, "Branch is required"),
|
||||||
.string()
|
|
||||||
.min(1, "Branch is required")
|
|
||||||
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
|
|
||||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
@@ -78,10 +74,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
|
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
|
||||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||||
|
|
||||||
const { mutateAsync, isPending: isSavingGitlabProvider } =
|
const { mutateAsync, isLoading: isSavingGitlabProvider } =
|
||||||
api.application.saveGitlabProvider.useMutation();
|
api.application.saveGitlabProvider.useMutation();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm<GitlabProvider>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
buildPath: "/",
|
buildPath: "/",
|
||||||
repository: {
|
repository: {
|
||||||
@@ -258,13 +254,13 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!field.value.owner
|
{isLoadingRepositories
|
||||||
? "Select repository"
|
? "Loading...."
|
||||||
: isLoadingRepositories
|
: field.value.owner
|
||||||
? "Loading...."
|
? repositories?.find(
|
||||||
: (repositories?.find(
|
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name ?? "Select repository")}
|
)?.name
|
||||||
|
: "Select repository"}
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -276,15 +272,11 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{!gitlabId ? (
|
{isLoadingRepositories && (
|
||||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
Select a GitLab account first
|
|
||||||
</span>
|
|
||||||
) : isLoadingRepositories ? (
|
|
||||||
<span className="py-6 text-center text-sm">
|
<span className="py-6 text-center text-sm">
|
||||||
Loading Repositories....
|
Loading Repositories....
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
)}
|
||||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-96">
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
@@ -355,7 +347,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === "pending" && fetchStatus === "fetching"
|
{status === "loading" && fetchStatus === "fetching"
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: field.value
|
: field.value
|
||||||
? branches?.find(
|
? branches?.find(
|
||||||
@@ -372,7 +364,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search branch..."
|
placeholder="Search branch..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{status === "pending" && fetchStatus === "fetching" && (
|
{status === "loading" && fetchStatus === "fetching" && (
|
||||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||||
Loading Branches....
|
Loading Branches....
|
||||||
</span>
|
</span>
|
||||||
@@ -452,7 +444,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<div className="flex flex-wrap gap-2 mb-2">
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
{field.value?.map((path, index) => (
|
{field.value?.map((path, index) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={`${path}-${index}`}
|
key={index}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -36,13 +36,13 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowProviderForm = ({ applicationId }: Props) => {
|
export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||||
const { data: githubProviders, isPending: isLoadingGithub } =
|
const { data: githubProviders, isLoading: isLoadingGithub } =
|
||||||
api.github.githubProviders.useQuery();
|
api.github.githubProviders.useQuery();
|
||||||
const { data: gitlabProviders, isPending: isLoadingGitlab } =
|
const { data: gitlabProviders, isLoading: isLoadingGitlab } =
|
||||||
api.gitlab.gitlabProviders.useQuery();
|
api.gitlab.gitlabProviders.useQuery();
|
||||||
const { data: bitbucketProviders, isPending: isLoadingBitbucket } =
|
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
|
||||||
api.bitbucket.bitbucketProviders.useQuery();
|
api.bitbucket.bitbucketProviders.useQuery();
|
||||||
const { data: giteaProviders, isPending: isLoadingGitea } =
|
const { data: giteaProviders, isLoading: isLoadingGitea } =
|
||||||
api.gitea.giteaProviders.useQuery();
|
api.gitea.giteaProviders.useQuery();
|
||||||
|
|
||||||
const { data: application, refetch } = api.application.one.useQuery({
|
const { data: application, refetch } = api.application.one.useQuery({
|
||||||
|
|||||||
@@ -30,9 +30,6 @@ interface Props {
|
|||||||
|
|
||||||
export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
|
||||||
const canDeploy = permissions?.deployment.create ?? false;
|
|
||||||
const canUpdateService = permissions?.service.create ?? false;
|
|
||||||
const { data, refetch } = api.application.one.useQuery(
|
const { data, refetch } = api.application.one.useQuery(
|
||||||
{
|
{
|
||||||
applicationId,
|
applicationId,
|
||||||
@@ -40,14 +37,14 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
{ enabled: !!applicationId },
|
{ enabled: !!applicationId },
|
||||||
);
|
);
|
||||||
const { mutateAsync: update } = api.application.update.useMutation();
|
const { mutateAsync: update } = api.application.update.useMutation();
|
||||||
const { mutateAsync: start, isPending: isStarting } =
|
const { mutateAsync: start, isLoading: isStarting } =
|
||||||
api.application.start.useMutation();
|
api.application.start.useMutation();
|
||||||
const { mutateAsync: stop, isPending: isStopping } =
|
const { mutateAsync: stop, isLoading: isStopping } =
|
||||||
api.application.stop.useMutation();
|
api.application.stop.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: deploy } = api.application.deploy.useMutation();
|
const { mutateAsync: deploy } = api.application.deploy.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: reload, isPending: isReloading } =
|
const { mutateAsync: reload, isLoading: isReloading } =
|
||||||
api.application.reload.useMutation();
|
api.application.reload.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: redeploy } = api.application.redeploy.useMutation();
|
const { mutateAsync: redeploy } = api.application.redeploy.useMutation();
|
||||||
@@ -58,137 +55,130 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-2 lg:flex lg:flex-row lg:flex-wrap gap-4">
|
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||||
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
||||||
{canDeploy && (
|
<DialogAction
|
||||||
<DialogAction
|
title="Deploy Application"
|
||||||
title="Deploy Application"
|
description="Are you sure you want to deploy this application?"
|
||||||
description="Are you sure you want to deploy this application?"
|
type="default"
|
||||||
type="default"
|
onClick={async () => {
|
||||||
onClick={async () => {
|
await deploy({
|
||||||
await deploy({
|
applicationId: applicationId,
|
||||||
applicationId: applicationId,
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Application deployed successfully");
|
||||||
|
refetch();
|
||||||
|
router.push(
|
||||||
|
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.then(() => {
|
.catch(() => {
|
||||||
toast.success("Application deployed successfully");
|
toast.error("Error deploying application");
|
||||||
refetch();
|
});
|
||||||
router.push(
|
}}
|
||||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
|
>
|
||||||
);
|
<Button
|
||||||
})
|
variant="default"
|
||||||
.catch(() => {
|
isLoading={data?.applicationStatus === "running"}
|
||||||
toast.error("Error deploying application");
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Tooltip>
|
||||||
variant="default"
|
<TooltipTrigger asChild>
|
||||||
isLoading={data?.applicationStatus === "running"}
|
<div className="flex items-center">
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
<Rocket className="size-4 mr-1" />
|
||||||
>
|
Deploy
|
||||||
<Tooltip>
|
</div>
|
||||||
<TooltipTrigger asChild>
|
</TooltipTrigger>
|
||||||
<div className="flex items-center">
|
<TooltipPrimitive.Portal>
|
||||||
<Rocket className="size-4 mr-1" />
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
Deploy
|
<p>
|
||||||
</div>
|
Downloads the source code and performs a complete build
|
||||||
</TooltipTrigger>
|
</p>
|
||||||
<TooltipPrimitive.Portal>
|
</TooltipContent>
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
</TooltipPrimitive.Portal>
|
||||||
<p>
|
</Tooltip>
|
||||||
Downloads the source code and performs a complete
|
</Button>
|
||||||
build
|
</DialogAction>
|
||||||
</p>
|
<DialogAction
|
||||||
</TooltipContent>
|
title="Reload Application"
|
||||||
</TooltipPrimitive.Portal>
|
description="Are you sure you want to reload this application?"
|
||||||
</Tooltip>
|
type="default"
|
||||||
</Button>
|
onClick={async () => {
|
||||||
</DialogAction>
|
await reload({
|
||||||
)}
|
applicationId: applicationId,
|
||||||
{canDeploy && (
|
appName: data?.appName || "",
|
||||||
<DialogAction
|
})
|
||||||
title="Reload Application"
|
.then(() => {
|
||||||
description="Are you sure you want to reload this application?"
|
toast.success("Application reloaded successfully");
|
||||||
type="default"
|
refetch();
|
||||||
onClick={async () => {
|
|
||||||
await reload({
|
|
||||||
applicationId: applicationId,
|
|
||||||
appName: data?.appName || "",
|
|
||||||
})
|
})
|
||||||
.then(() => {
|
.catch(() => {
|
||||||
toast.success("Application reloaded successfully");
|
toast.error("Error reloading application");
|
||||||
refetch();
|
});
|
||||||
})
|
}}
|
||||||
.catch(() => {
|
>
|
||||||
toast.error("Error reloading application");
|
<Button
|
||||||
});
|
variant="secondary"
|
||||||
}}
|
isLoading={isReloading}
|
||||||
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<Button
|
<Tooltip>
|
||||||
variant="secondary"
|
<TooltipTrigger asChild>
|
||||||
isLoading={isReloading}
|
<div className="flex items-center">
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
<RefreshCcw className="size-4 mr-1" />
|
||||||
>
|
Reload
|
||||||
<Tooltip>
|
</div>
|
||||||
<TooltipTrigger asChild>
|
</TooltipTrigger>
|
||||||
<div className="flex items-center">
|
<TooltipPrimitive.Portal>
|
||||||
<RefreshCcw className="size-4 mr-1" />
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
Reload
|
<p>Reload the application without rebuilding it</p>
|
||||||
</div>
|
</TooltipContent>
|
||||||
</TooltipTrigger>
|
</TooltipPrimitive.Portal>
|
||||||
<TooltipPrimitive.Portal>
|
</Tooltip>
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
</Button>
|
||||||
<p>Reload the application without rebuilding it</p>
|
</DialogAction>
|
||||||
</TooltipContent>
|
<DialogAction
|
||||||
</TooltipPrimitive.Portal>
|
title="Rebuild Application"
|
||||||
</Tooltip>
|
description="Are you sure you want to rebuild this application?"
|
||||||
</Button>
|
type="default"
|
||||||
</DialogAction>
|
onClick={async () => {
|
||||||
)}
|
await redeploy({
|
||||||
{canDeploy && (
|
applicationId: applicationId,
|
||||||
<DialogAction
|
})
|
||||||
title="Rebuild Application"
|
.then(() => {
|
||||||
description="Are you sure you want to rebuild this application?"
|
toast.success("Application rebuilt successfully");
|
||||||
type="default"
|
refetch();
|
||||||
onClick={async () => {
|
|
||||||
await redeploy({
|
|
||||||
applicationId: applicationId,
|
|
||||||
})
|
})
|
||||||
.then(() => {
|
.catch(() => {
|
||||||
toast.success("Application rebuilt successfully");
|
toast.error("Error rebuilding application");
|
||||||
refetch();
|
});
|
||||||
})
|
}}
|
||||||
.catch(() => {
|
>
|
||||||
toast.error("Error rebuilding application");
|
<Button
|
||||||
});
|
variant="secondary"
|
||||||
}}
|
isLoading={data?.applicationStatus === "running"}
|
||||||
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<Button
|
<Tooltip>
|
||||||
variant="secondary"
|
<TooltipTrigger asChild>
|
||||||
isLoading={data?.applicationStatus === "running"}
|
<div className="flex items-center">
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
<Hammer className="size-4 mr-1" />
|
||||||
>
|
Rebuild
|
||||||
<Tooltip>
|
</div>
|
||||||
<TooltipTrigger asChild>
|
</TooltipTrigger>
|
||||||
<div className="flex items-center">
|
<TooltipPrimitive.Portal>
|
||||||
<Hammer className="size-4 mr-1" />
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
Rebuild
|
<p>
|
||||||
</div>
|
Only rebuilds the application without downloading new
|
||||||
</TooltipTrigger>
|
code
|
||||||
<TooltipPrimitive.Portal>
|
</p>
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
</TooltipContent>
|
||||||
<p>
|
</TooltipPrimitive.Portal>
|
||||||
Only rebuilds the application without downloading new
|
</Tooltip>
|
||||||
code
|
</Button>
|
||||||
</p>
|
</DialogAction>
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPrimitive.Portal>
|
|
||||||
</Tooltip>
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canDeploy && data?.applicationStatus === "idle" ? (
|
{data?.applicationStatus === "idle" ? (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Start Application"
|
title="Start Application"
|
||||||
description="Are you sure you want to start this application?"
|
description="Are you sure you want to start this application?"
|
||||||
@@ -229,7 +219,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
) : canDeploy ? (
|
) : (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Stop Application"
|
title="Stop Application"
|
||||||
description="Are you sure you want to stop this application?"
|
description="Are you sure you want to stop this application?"
|
||||||
@@ -266,7 +256,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
) : null}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<DockerTerminalModal
|
<DockerTerminalModal
|
||||||
appName={data?.appName || ""}
|
appName={data?.appName || ""}
|
||||||
@@ -274,59 +264,55 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2 col-span-2"
|
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<Terminal className="size-4 mr-1" />
|
<Terminal className="size-4 mr-1" />
|
||||||
Open Terminal
|
Open Terminal
|
||||||
</Button>
|
</Button>
|
||||||
</DockerTerminalModal>
|
</DockerTerminalModal>
|
||||||
{canUpdateService && (
|
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||||
<div className="flex flex-row items-center gap-2 justify-between rounded-md px-4 py-2 border col-span-2 md:col-span-1">
|
<span className="text-sm font-medium">Autodeploy</span>
|
||||||
<span className="text-sm font-medium">Autodeploy</span>
|
<Switch
|
||||||
<Switch
|
aria-label="Toggle autodeploy"
|
||||||
aria-label="Toggle autodeploy"
|
checked={data?.autoDeploy || false}
|
||||||
checked={data?.autoDeploy || false}
|
onCheckedChange={async (enabled) => {
|
||||||
onCheckedChange={async (enabled) => {
|
await update({
|
||||||
await update({
|
applicationId,
|
||||||
applicationId,
|
autoDeploy: enabled,
|
||||||
autoDeploy: enabled,
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Auto Deploy Updated");
|
||||||
|
await refetch();
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.catch(() => {
|
||||||
toast.success("Auto Deploy Updated");
|
toast.error("Error updating Auto Deploy");
|
||||||
await refetch();
|
});
|
||||||
})
|
}}
|
||||||
.catch(() => {
|
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||||
toast.error("Error updating Auto Deploy");
|
/>
|
||||||
});
|
</div>
|
||||||
}}
|
|
||||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canUpdateService && (
|
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||||
<div className="flex flex-row items-center gap-2 justify-between rounded-md px-4 py-2 border col-span-2 md:col-span-1">
|
<span className="text-sm font-medium">Clean Cache</span>
|
||||||
<span className="text-sm font-medium">Clean Cache</span>
|
<Switch
|
||||||
<Switch
|
aria-label="Toggle clean cache"
|
||||||
aria-label="Toggle clean cache"
|
checked={data?.cleanCache || false}
|
||||||
checked={data?.cleanCache || false}
|
onCheckedChange={async (enabled) => {
|
||||||
onCheckedChange={async (enabled) => {
|
await update({
|
||||||
await update({
|
applicationId,
|
||||||
applicationId,
|
cleanCache: enabled,
|
||||||
cleanCache: enabled,
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Clean Cache Updated");
|
||||||
|
await refetch();
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.catch(() => {
|
||||||
toast.success("Clean Cache Updated");
|
toast.error("Error updating Clean Cache");
|
||||||
await refetch();
|
});
|
||||||
})
|
}}
|
||||||
.catch(() => {
|
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||||
toast.error("Error updating Clean Cache");
|
/>
|
||||||
});
|
</div>
|
||||||
}}
|
|
||||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<ShowProviderForm applicationId={applicationId} />
|
<ShowProviderForm applicationId={applicationId} />
|
||||||
|
|||||||
@@ -1,277 +0,0 @@
|
|||||||
import DOMPurify from "dompurify";
|
|
||||||
import { GlobeIcon, Pencil, Search, X } from "lucide-react";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
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";
|
|
||||||
|
|
||||||
interface ShowIconSettingsProps {
|
|
||||||
applicationId: string;
|
|
||||||
icon?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const svgToDataUrl = (icon: BundledIcon): string => {
|
|
||||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#${icon.hex}"><path d="${icon.path}"/></svg>`;
|
|
||||||
return `data:image/svg+xml;base64,${btoa(svg)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ShowIconSettings = ({
|
|
||||||
applicationId,
|
|
||||||
icon,
|
|
||||||
}: ShowIconSettingsProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [iconSearchQuery, setIconSearchQuery] = useState("");
|
|
||||||
const [iconsToShow, setIconsToShow] = useState(24);
|
|
||||||
|
|
||||||
const filteredIcons = useMemo(() => {
|
|
||||||
if (!iconSearchQuery) return bundledIcons;
|
|
||||||
const q = iconSearchQuery.toLowerCase();
|
|
||||||
return bundledIcons.filter(
|
|
||||||
(i) =>
|
|
||||||
i.title.toLowerCase().includes(q) || i.slug.toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
}, [iconSearchQuery]);
|
|
||||||
|
|
||||||
const displayedIcons = filteredIcons.slice(0, iconsToShow);
|
|
||||||
const hasMoreIcons = filteredIcons.length > iconsToShow;
|
|
||||||
|
|
||||||
const utils = api.useUtils();
|
|
||||||
const { mutateAsync: updateApplication } =
|
|
||||||
api.application.update.useMutation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setIconSearchQuery("");
|
|
||||||
setIconsToShow(24);
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const handleIconSelect = async (selectedIcon: BundledIcon) => {
|
|
||||||
try {
|
|
||||||
const dataUrl = svgToDataUrl(selectedIcon);
|
|
||||||
await updateApplication({
|
|
||||||
applicationId,
|
|
||||||
icon: dataUrl,
|
|
||||||
});
|
|
||||||
toast.success("Icon saved successfully");
|
|
||||||
await utils.application.one.invalidate({ applicationId });
|
|
||||||
setOpen(false);
|
|
||||||
} catch (_error) {
|
|
||||||
toast.error("Error saving icon");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveIcon = async () => {
|
|
||||||
try {
|
|
||||||
await updateApplication({
|
|
||||||
applicationId,
|
|
||||||
icon: null,
|
|
||||||
});
|
|
||||||
toast.success("Icon removed");
|
|
||||||
await utils.application.one.invalidate({ applicationId });
|
|
||||||
} catch (_error) {
|
|
||||||
toast.error("Error removing icon");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
const allowedTypes = [
|
|
||||||
"image/jpeg",
|
|
||||||
"image/jpg",
|
|
||||||
"image/png",
|
|
||||||
"image/svg+xml",
|
|
||||||
];
|
|
||||||
const fileExtension = file.name.split(".").pop()?.toLowerCase();
|
|
||||||
const allowedExtensions = ["jpg", "jpeg", "png", "svg"];
|
|
||||||
|
|
||||||
if (
|
|
||||||
!allowedTypes.includes(file.type) &&
|
|
||||||
!allowedExtensions.includes(fileExtension || "")
|
|
||||||
) {
|
|
||||||
toast.error("Only JPG, JPEG, PNG, and SVG files are allowed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > 2 * 1024 * 1024) {
|
|
||||||
toast.error("Image size must be less than 2MB");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSvg = file.type === "image/svg+xml" || fileExtension === "svg";
|
|
||||||
|
|
||||||
if (isSvg) {
|
|
||||||
const text = await file.text();
|
|
||||||
const sanitizedDataUrl = sanitizeSvg(text);
|
|
||||||
if (!sanitizedDataUrl) {
|
|
||||||
toast.error("Invalid SVG file");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await updateApplication({
|
|
||||||
applicationId,
|
|
||||||
icon: sanitizedDataUrl,
|
|
||||||
});
|
|
||||||
toast.success("Icon saved!");
|
|
||||||
await utils.application.one.invalidate({ applicationId });
|
|
||||||
setOpen(false);
|
|
||||||
} catch (_error) {
|
|
||||||
toast.error("Error saving icon");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = async (event) => {
|
|
||||||
const result = event.target?.result as string;
|
|
||||||
try {
|
|
||||||
await updateApplication({
|
|
||||||
applicationId,
|
|
||||||
icon: result,
|
|
||||||
});
|
|
||||||
toast.success("Icon saved!");
|
|
||||||
await utils.application.one.invalidate({ applicationId });
|
|
||||||
setOpen(false);
|
|
||||||
} catch (_error) {
|
|
||||||
toast.error("Error saving icon");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="relative group flex items-center justify-center"
|
|
||||||
>
|
|
||||||
{icon ? (
|
|
||||||
// biome-ignore lint/performance/noImgElement: icon is data URL or base64
|
|
||||||
<img
|
|
||||||
src={icon}
|
|
||||||
alt="Application icon"
|
|
||||||
className="h-8 w-8 object-contain"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<GlobeIcon className="h-6 w-6 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<Pencil className="h-3 w-3 text-white" />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center justify-between">
|
|
||||||
Change Icon
|
|
||||||
{icon && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRemoveIcon}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
<X className="size-4 mr-1" />
|
|
||||||
Remove icon
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search icons (e.g. react, vue, docker)..."
|
|
||||||
value={iconSearchQuery}
|
|
||||||
onChange={(e) => setIconSearchQuery(e.target.value)}
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-[300px] overflow-y-auto border rounded-lg p-4">
|
|
||||||
{displayedIcons.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
|
||||||
No icons found
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
|
|
||||||
{displayedIcons.map((i) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={i.slug}
|
|
||||||
onClick={() => handleIconSelect(i)}
|
|
||||||
className="flex flex-col items-center gap-1.5 p-2 rounded-lg border hover:border-primary hover:bg-muted transition-colors group"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
className="size-7 group-hover:scale-110 transition-transform"
|
|
||||||
fill={`#${i.hex}`}
|
|
||||||
>
|
|
||||||
<path d={i.path} />
|
|
||||||
</svg>
|
|
||||||
<span className="text-[10px] text-muted-foreground capitalize truncate w-full text-center">
|
|
||||||
{i.title}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{hasMoreIcons && (
|
|
||||||
<div className="flex justify-center mt-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIconsToShow((prev) => prev + 24)}
|
|
||||||
>
|
|
||||||
Load More ({filteredIcons.length - iconsToShow} remaining)
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative pt-3 border-t">
|
|
||||||
<p className="text-sm text-muted-foreground text-center mb-3">
|
|
||||||
or upload a custom icon
|
|
||||||
</p>
|
|
||||||
<Dropzone
|
|
||||||
dropMessage="Drag & drop an icon or click to upload"
|
|
||||||
accept=".jpg,.jpeg,.png,.svg,image/jpeg,image/png,image/svg+xml"
|
|
||||||
onChange={handleFileUpload}
|
|
||||||
classNameWrapper="border-2 border-dashed border-border hover:border-primary bg-muted/30 hover:bg-muted/50 transition-all rounded-lg"
|
|
||||||
/>
|
|
||||||
<div className="mt-2 text-center text-xs text-muted-foreground">
|
|
||||||
Supported formats: JPG, JPEG, PNG, SVG (max 2MB)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -56,7 +56,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
|||||||
const [containerId, setContainerId] = useState<string | undefined>();
|
const [containerId, setContainerId] = useState<string | undefined>();
|
||||||
const [option, setOption] = useState<"swarm" | "native">("native");
|
const [option, setOption] = useState<"swarm" | "native">("native");
|
||||||
|
|
||||||
const { data: services, isPending: servicesLoading } =
|
const { data: services, isLoading: servicesLoading } =
|
||||||
api.docker.getServiceContainersByAppName.useQuery(
|
api.docker.getServiceContainersByAppName.useQuery(
|
||||||
{
|
{
|
||||||
appName,
|
appName,
|
||||||
@@ -67,7 +67,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: containers, isPending: containersLoading } =
|
const { data: containers, isLoading: containersLoading } =
|
||||||
api.docker.getContainersByAppNameMatch.useQuery(
|
api.docker.getContainersByAppNameMatch.useQuery(
|
||||||
{
|
{
|
||||||
appName,
|
appName,
|
||||||
@@ -91,7 +91,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
|||||||
}, [option, services, containers]);
|
}, [option, services, containers]);
|
||||||
|
|
||||||
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
||||||
const containersLength =
|
const containersLenght =
|
||||||
option === "native" ? containers?.length : services?.length;
|
option === "native" ? containers?.length : services?.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -167,7 +167,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SelectLabel>Containers ({containersLength})</SelectLabel>
|
<SelectLabel>Containers ({containersLenght})</SelectLabel>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user