mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
Merge branch 'canary' into 2731-wrong-extension-for-mongo-backup-file
This commit is contained in:
21
.devcontainer/Dockerfile
Normal file
21
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# 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
|
||||||
53
.devcontainer/devcontainer.json
Normal file
53
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
6
.github/pull_request_template.md
vendored
6
.github/pull_request_template.md
vendored
@@ -6,9 +6,9 @@ Please describe in a short paragraph what this PR is about.
|
|||||||
|
|
||||||
Before submitting this PR, please make sure that:
|
Before submitting this PR, please make sure that:
|
||||||
|
|
||||||
- [] You created a dedicated branch based on the `canary` branch.
|
- [ ] You created a dedicated branch based on the `canary` branch.
|
||||||
- [] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
|
- [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
|
||||||
- [] You have tested this PR in your local instance.
|
- [ ] You have tested this PR in your local instance. If you have not tested it yet, please do so before submitting. This helps avoid wasting maintainers' time reviewing code that has not been verified by you.
|
||||||
|
|
||||||
## Issues related (if applicable)
|
## Issues related (if applicable)
|
||||||
|
|
||||||
|
|||||||
BIN
.github/sponsors/awesome.png
vendored
Normal file
BIN
.github/sponsors/awesome.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
40
.github/workflows/deploy.yml
vendored
40
.github/workflows/deploy.yml
vendored
@@ -13,6 +13,17 @@ 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:
|
||||||
@@ -25,8 +36,7 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.cloud
|
file: ./Dockerfile.cloud
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: ${{ steps.meta-cloud.outputs.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 }}
|
||||||
@@ -40,6 +50,16 @@ 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:
|
||||||
@@ -52,8 +72,7 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.schedule
|
file: ./Dockerfile.schedule
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: ${{ steps.meta-schedule.outputs.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:
|
||||||
@@ -63,6 +82,16 @@ 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:
|
||||||
@@ -75,6 +104,5 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.server
|
file: ./Dockerfile.server
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: ${{ steps.meta-server.outputs.tags }}
|
||||||
siumauricio/server:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
|
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
|
|||||||
21
.github/workflows/pr-quality.yml
vendored
Normal file
21
.github/workflows/pr-quality.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
name: PR Quality
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
anti-slop:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: peakoss/anti-slop@v0
|
||||||
|
with:
|
||||||
|
blocked-commit-authors: "claude,copilot"
|
||||||
|
require-description: true
|
||||||
|
min-account-age: 5
|
||||||
28
.github/workflows/pull-request.yml
vendored
28
.github/workflows/pull-request.yml
vendored
@@ -18,8 +18,34 @@ 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: 20.16.0
|
node-version: 24.4.0
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- name: Install Nixpacks
|
||||||
|
if: matrix.job == 'test'
|
||||||
|
run: |
|
||||||
|
export NIXPACKS_VERSION=1.41.0
|
||||||
|
curl -sSL https://nixpacks.com/install.sh | bash
|
||||||
|
echo "Nixpacks installed $NIXPACKS_VERSION"
|
||||||
|
|
||||||
|
- name: Install Railpack
|
||||||
|
if: matrix.job == 'test'
|
||||||
|
run: |
|
||||||
|
export RAILPACK_VERSION=0.15.4
|
||||||
|
curl -sSL https://railpack.com/install.sh | bash
|
||||||
|
echo "Railpack installed $RAILPACK_VERSION"
|
||||||
|
|
||||||
|
- name: Add build tools to PATH
|
||||||
|
if: matrix.job == 'test'
|
||||||
|
run: echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Initialize Docker Swarm
|
||||||
|
if: matrix.job == 'test'
|
||||||
|
run: |
|
||||||
|
docker swarm init
|
||||||
|
docker network create --driver overlay dokploy-network || true
|
||||||
|
echo "✅ Docker Swarm initialized"
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm server:build
|
- run: pnpm server:build
|
||||||
- run: pnpm ${{ matrix.job }}
|
- run: pnpm ${{ matrix.job }}
|
||||||
|
|||||||
70
.github/workflows/sync-openapi-docs.yml
vendored
Normal file
70
.github/workflows/sync-openapi-docs.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
name: Generate and Sync OpenAPI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- canary
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'apps/dokploy/server/api/routers/**'
|
||||||
|
- 'packages/server/src/services/**'
|
||||||
|
- 'packages/server/src/db/schema/**'
|
||||||
|
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
generate-and-commit:
|
||||||
|
name: Generate OpenAPI and commit to Dokploy repo
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Dokploy repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24.4.0
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Generate OpenAPI specification
|
||||||
|
run: |
|
||||||
|
pnpm generate:openapi
|
||||||
|
|
||||||
|
# Verifica que se generó correctamente
|
||||||
|
if [ ! -f openapi.json ]; then
|
||||||
|
echo "❌ openapi.json not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ OpenAPI specification generated successfully"
|
||||||
|
|
||||||
|
- name: Sync to website repository
|
||||||
|
run: |
|
||||||
|
# Clona el repositorio de website
|
||||||
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/website.git website-repo
|
||||||
|
|
||||||
|
cd website-repo
|
||||||
|
|
||||||
|
# Copia el openapi.json al website (sobrescribe)
|
||||||
|
mkdir -p apps/docs/public
|
||||||
|
cp -f ../openapi.json apps/docs/public/openapi.json
|
||||||
|
|
||||||
|
# Configura git
|
||||||
|
git config user.name "Dokploy Bot"
|
||||||
|
git config user.email "bot@dokploy.com"
|
||||||
|
|
||||||
|
# Agrega y commitea siempre
|
||||||
|
git add apps/docs/public/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 website successfully"
|
||||||
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,6 +13,8 @@ node_modules
|
|||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
|
||||||
|
openapi.json
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage
|
coverage
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Hey, thanks for your interest in contributing to Dokploy! We appreciate your help and taking your time to contribute.
|
Hey, thanks for your interest in contributing to Dokploy! We appreciate your help and taking your time to contribute.
|
||||||
|
|
||||||
Before you start, please first discuss the feature/bug you want to add with the owners and comunity via github issues.
|
Before you start, please first discuss the feature/bug you want to add with the owners and community via github issues.
|
||||||
|
|
||||||
We have a few guidelines to follow when contributing to this project:
|
We have a few guidelines to follow when contributing to this project:
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ We have a few guidelines to follow when contributing to this project:
|
|||||||
- [Development](#development)
|
- [Development](#development)
|
||||||
- [Build](#build)
|
- [Build](#build)
|
||||||
- [Pull Request](#pull-request)
|
- [Pull Request](#pull-request)
|
||||||
|
- [Important Considerations](#important-considerations-for-pull-requests)
|
||||||
|
|
||||||
## Commit Convention
|
## Commit Convention
|
||||||
|
|
||||||
@@ -52,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 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.
|
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.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/dokploy/dokploy.git
|
git clone https://github.com/dokploy/dokploy.git
|
||||||
@@ -155,7 +156,7 @@ curl -sSL https://railpack.com/install.sh | sh
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install Buildpacks
|
# Install Buildpacks
|
||||||
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.39.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
||||||
```
|
```
|
||||||
|
|
||||||
## Pull Request
|
## Pull Request
|
||||||
@@ -169,11 +170,13 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.
|
|||||||
- If your pull request fixes an open issue, please reference the issue in the pull request description.
|
- If your pull request fixes an open issue, please reference the issue in the pull request description.
|
||||||
- Once your pull request is merged, you will be automatically added as a contributor to the project.
|
- Once your pull request is merged, you will be automatically added as a contributor to the project.
|
||||||
|
|
||||||
**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.
|
||||||
- **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!
|
||||||
|
|
||||||
|
|||||||
20
Dockerfile
20
Dockerfile
@@ -1,9 +1,9 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
FROM node:20.16.0-slim AS base
|
FROM node:24.4.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@9.12.0 --activate
|
RUN corepack prepare pnpm@10.22.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 /prod/dokploy
|
RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /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
|
||||||
@@ -46,23 +46,27 @@ COPY --from=build /prod/dokploy/node_modules ./node_modules
|
|||||||
|
|
||||||
|
|
||||||
# Install docker
|
# Install docker
|
||||||
RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm get-docker.sh && curl https://rclone.org/install.sh | bash
|
RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --version 28.5.2 && rm get-docker.sh && curl https://rclone.org/install.sh | bash
|
||||||
|
|
||||||
# Install Nixpacks and tsx
|
# Install Nixpacks and tsx
|
||||||
# | VERBOSE=1 VERSION=1.21.0 bash
|
# | VERBOSE=1 VERSION=1.21.0 bash
|
||||||
|
|
||||||
ARG NIXPACKS_VERSION=1.39.0
|
ARG NIXPACKS_VERSION=1.41.0
|
||||||
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
||||||
&& chmod +x install.sh \
|
&& chmod +x install.sh \
|
||||||
&& ./install.sh \
|
&& ./install.sh \
|
||||||
&& pnpm install -g tsx
|
&& pnpm install -g tsx
|
||||||
|
|
||||||
# Install Railpack
|
# Install Railpack
|
||||||
ARG RAILPACK_VERSION=0.2.2
|
ARG RAILPACK_VERSION=0.15.4
|
||||||
RUN curl -sSL https://railpack.com/install.sh | bash
|
RUN curl -sSL https://railpack.com/install.sh | bash
|
||||||
|
|
||||||
# Install buildpacks
|
# Install buildpacks
|
||||||
COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack
|
COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD [ "pnpm", "start" ]
|
|
||||||
|
HEALTHCHECK --interval=10s --timeout=3s --retries=10 \
|
||||||
|
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
||||||
|
|
||||||
|
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:20.16.0-slim AS base
|
FROM node:24.4.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@9.12.0 --activate
|
RUN corepack prepare pnpm@10.22.0 --activate
|
||||||
|
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
COPY . /usr/src/app
|
COPY . /usr/src/app
|
||||||
@@ -16,11 +16,11 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter=@dokploy/server
|
|||||||
|
|
||||||
|
|
||||||
# Deploy only the dokploy app
|
# Deploy only the dokploy app
|
||||||
ARG NEXT_PUBLIC_UMAMI_HOST
|
# ARG NEXT_PUBLIC_UMAMI_HOST
|
||||||
ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
|
# ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
|
||||||
|
|
||||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
# ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
# ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
|
|
||||||
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||||
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||||
@@ -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 /prod/dokploy
|
RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /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:20.16.0-slim AS base
|
FROM node:24.4.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@9.12.0 --activate
|
RUN corepack prepare pnpm@10.22.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 /prod/schedules
|
RUN pnpm --filter=./apps/schedules --prod deploy --legacy /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
|
||||||
|
|
||||||
@@ -35,4 +35,5 @@ COPY --from=build /prod/schedules/dist ./dist
|
|||||||
COPY --from=build /prod/schedules/package.json ./package.json
|
COPY --from=build /prod/schedules/package.json ./package.json
|
||||||
COPY --from=build /prod/schedules/node_modules ./node_modules
|
COPY --from=build /prod/schedules/node_modules ./node_modules
|
||||||
|
|
||||||
CMD HOSTNAME=0.0.0.0 && pnpm start
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
CMD ["pnpm", "start"]
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
FROM node:20.16.0-slim AS base
|
FROM node:24.4.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@9.12.0 --activate
|
RUN corepack prepare pnpm@10.22.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 /prod/api
|
RUN pnpm --filter=./apps/api --prod deploy --legacy /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
|
||||||
|
|
||||||
@@ -35,4 +35,5 @@ COPY --from=build /prod/api/dist ./dist
|
|||||||
COPY --from=build /prod/api/package.json ./package.json
|
COPY --from=build /prod/api/package.json ./package.json
|
||||||
COPY --from=build /prod/api/node_modules ./node_modules
|
COPY --from=build /prod/api/node_modules ./node_modules
|
||||||
|
|
||||||
CMD HOSTNAME=0.0.0.0 && pnpm start
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
CMD ["pnpm", "start"]
|
||||||
|
|||||||
19
LICENSE.MD
19
LICENSE.MD
@@ -1,8 +1,13 @@
|
|||||||
# License
|
Copyright 2026-present Dokploy Technology, Inc.
|
||||||
|
|
||||||
## Core License (Apache License 2.0)
|
Portions of this software are licensed as follows:
|
||||||
|
|
||||||
Copyright 2025 Mauricio Siu.
|
* All content that resides under a "/proprietary" directory of this repository, if that directory exists, is licensed under the license defined in "LICENSE_PROPRIETARY".
|
||||||
|
* Content outside of the above mentioned directories or restrictions above is available under the "Apache License 2.0" license as defined below.
|
||||||
|
|
||||||
|
## Apache License 2.0
|
||||||
|
|
||||||
|
Copyright 2026-present Dokploy Technology, Inc.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
@@ -15,12 +20,4 @@ distributed under the License is distributed on an "AS IS" BASIS,
|
|||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
See the License for the specific language governing permissions and limitations under the License.
|
See the License for the specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
## Additional Terms for Specific Features
|
|
||||||
|
|
||||||
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
|
|
||||||
|
|
||||||
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
|
|
||||||
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
|
|
||||||
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
|
|
||||||
|
|
||||||
For further inquiries or permissions, please contact us directly.
|
|
||||||
|
|||||||
11
LICENSE_PROPRIETARY.md
Normal file
11
LICENSE_PROPRIETARY.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
The Dokploy Source Available license (DSAL) version 1.0
|
||||||
|
|
||||||
|
Copyright (c) 2026-present Dokploy Technology, Inc.
|
||||||
|
|
||||||
|
With regard to the Dokploy Software:This software and associated documentation files (the "Software") may only beused in production, if you (and any entity that you represent) have agreed to, and are in compliance with, a valid commercial agreement from Dokploy.Subject to the foregoing sentence, you are free to modify this Software and publish patches to the Software. You agree that Dokploy and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications and/or patches, and all such modifications and/or patches may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid Dokploy Source Available License. Notwithstanding the foregoing, you may copy and modify the Software for development and testing purposes, without requiring a subscription. You agree that Dokploy and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications. You are not granted any other rights beyond what is expressly stated herein. Subject to theforegoing, it is forbidden to copy, merge, publish, distribute, sublicense,and/or sell the Software.
|
||||||
|
|
||||||
|
This Dokploy Source Available license applies only to the part of this Software that is in a /proprietary folder. The full text of this License shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THESOFTWARE.
|
||||||
|
|
||||||
|
For all third party components incorporated into the Dokploy Software, thosecomponents are licensed under the original license provided by the owner of the applicable component.
|
||||||
75
README.md
75
README.md
@@ -12,30 +12,14 @@
|
|||||||
<br />
|
<br />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div align="center" markdown="1">
|
|
||||||
<sup>Special thanks to:</sup>
|
|
||||||
<br>
|
|
||||||
<br>
|
|
||||||
<a href="https://tuple.app/dokploy">
|
|
||||||
<img src=".github/sponsors/tuple.png" alt="Tuple's sponsorship image" width="400"/>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
### [Tuple, the premier screen sharing app for developers](https://tuple.app/dokploy)
|
|
||||||
[Available for MacOS & Windows](https://tuple.app/dokploy)<br>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.
|
Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.
|
||||||
|
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
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, and Redis.
|
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, libsql, 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.
|
||||||
@@ -60,66 +44,9 @@ 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).
|
||||||
|
|
||||||
## ♥️ Sponsors
|
|
||||||
|
|
||||||
🙏 We're deeply grateful to all our sponsors who make Dokploy possible! Your support helps cover the costs of hosting, testing, and developing new features.
|
|
||||||
|
|
||||||
[Dokploy Open Collective](https://opencollective.com/dokploy)
|
|
||||||
|
|
||||||
[Github Sponsors](https://github.com/sponsors/Siumauricio)
|
[Github Sponsors](https://github.com/sponsors/Siumauricio)
|
||||||
|
|
||||||
<!-- Hero Sponsors 🎖 -->
|
|
||||||
|
|
||||||
<!-- Add Hero Sponsors here -->
|
|
||||||
|
|
||||||
### Hero Sponsors 🎖
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy"><img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="300"/></a>
|
|
||||||
<a href="https://www.lxaer.com/?ref=dokploy"><img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Premium Supporters 🥇 -->
|
|
||||||
|
|
||||||
<!-- Add Premium Supporters here -->
|
|
||||||
|
|
||||||
### Premium Supporters 🥇
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<a href="https://supafort.com/?ref=dokploy"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="300"/></a>
|
|
||||||
<a href="https://agentdock.ai/?ref=dokploy"><img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Elite Contributors 🥈 -->
|
|
||||||
|
|
||||||
<!-- Add Elite Contributors here -->
|
|
||||||
|
|
||||||
### Elite Contributors 🥈
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<a href="https://americancloud.com/?ref=dokploy"><img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" width="300"/></a>
|
|
||||||
<a href="https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy"><img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" width="100"/></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### Supporting Members 🥉
|
|
||||||
|
|
||||||
<div>
|
|
||||||
|
|
||||||
<a href="https://cloudblast.io/?ref=dokploy"><img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" width="250px" alt="Cloudblast.io"/></a>
|
|
||||||
|
|
||||||
<a href="https://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### Community Backers 🤝
|
|
||||||
|
|
||||||
#### Organizations:
|
|
||||||
|
|
||||||
[Sponsors on Open Collective](https://opencollective.com/dokploy)
|
|
||||||
|
|
||||||
#### Individuals:
|
|
||||||
|
|
||||||
[](https://opencollective.com/dokploy)
|
|
||||||
|
|
||||||
### Contributors 🤝
|
### Contributors 🤝
|
||||||
|
|
||||||
<a href="https://github.com/dokploy/dokploy/graphs/contributors">
|
<a href="https://github.com/dokploy/dokploy/graphs/contributors">
|
||||||
|
|||||||
@@ -1,2 +1,11 @@
|
|||||||
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": "tsc --project tsconfig.json",
|
"build": "rimraf dist && tsc --project tsconfig.json",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
@@ -12,27 +12,27 @@
|
|||||||
"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.3.0",
|
"@hono/zod-validator": "0.7.6",
|
||||||
"@nerimity/mimiqueue": "1.2.3",
|
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"hono": "^4.7.10",
|
"hono": "^4.11.7",
|
||||||
"pino": "9.4.0",
|
"pino": "9.4.0",
|
||||||
"pino-pretty": "11.2.2",
|
"pino-pretty": "11.2.2",
|
||||||
"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": "^3.25.32"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.17.51",
|
"@types/node": "^24.4.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@9.12.0",
|
"packageManager": "pnpm@10.22.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.16.0",
|
"node": "^24.4.0",
|
||||||
"pnpm": ">=9.12.0"
|
"pnpm": ">=10.22.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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();
|
||||||
@@ -118,7 +119,6 @@ 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,6 +176,29 @@ 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"],
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
|
|||||||
titleLog: z.string().optional(),
|
titleLog: z.string().optional(),
|
||||||
descriptionLog: z.string().optional(),
|
descriptionLog: z.string().optional(),
|
||||||
server: z.boolean().optional(),
|
server: z.boolean().optional(),
|
||||||
type: z.enum(["deploy"]),
|
type: z.enum(["deploy", "redeploy"]),
|
||||||
applicationType: z.literal("application-preview"),
|
applicationType: z.literal("application-preview"),
|
||||||
serverId: z.string().min(1),
|
serverId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
|
|||||||
239
apps/api/src/service.ts
Normal file
239
apps/api/src/service.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
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);
|
||||||
|
};
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
deployRemoteApplication,
|
deployApplication,
|
||||||
deployRemoteCompose,
|
deployCompose,
|
||||||
deployRemotePreviewApplication,
|
deployPreviewApplication,
|
||||||
rebuildRemoteApplication,
|
rebuildApplication,
|
||||||
rebuildRemoteCompose,
|
rebuildCompose,
|
||||||
|
rebuildPreviewApplication,
|
||||||
updateApplicationStatus,
|
updateApplicationStatus,
|
||||||
updateCompose,
|
updateCompose,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import type { DeployJob } from "./schema";
|
import type { DeployJob } from "./schema.js";
|
||||||
|
|
||||||
export const deploy = async (job: DeployJob) => {
|
export const deploy = async (job: DeployJob) => {
|
||||||
try {
|
try {
|
||||||
@@ -16,13 +17,13 @@ export const deploy = async (job: DeployJob) => {
|
|||||||
await updateApplicationStatus(job.applicationId, "running");
|
await updateApplicationStatus(job.applicationId, "running");
|
||||||
if (job.server) {
|
if (job.server) {
|
||||||
if (job.type === "redeploy") {
|
if (job.type === "redeploy") {
|
||||||
await rebuildRemoteApplication({
|
await rebuildApplication({
|
||||||
applicationId: job.applicationId,
|
applicationId: job.applicationId,
|
||||||
titleLog: job.titleLog || "Rebuild deployment",
|
titleLog: job.titleLog || "Rebuild deployment",
|
||||||
descriptionLog: job.descriptionLog || "",
|
descriptionLog: job.descriptionLog || "",
|
||||||
});
|
});
|
||||||
} else if (job.type === "deploy") {
|
} else if (job.type === "deploy") {
|
||||||
await deployRemoteApplication({
|
await deployApplication({
|
||||||
applicationId: job.applicationId,
|
applicationId: job.applicationId,
|
||||||
titleLog: job.titleLog || "Manual deployment",
|
titleLog: job.titleLog || "Manual deployment",
|
||||||
descriptionLog: job.descriptionLog || "",
|
descriptionLog: job.descriptionLog || "",
|
||||||
@@ -36,13 +37,13 @@ export const deploy = async (job: DeployJob) => {
|
|||||||
|
|
||||||
if (job.server) {
|
if (job.server) {
|
||||||
if (job.type === "redeploy") {
|
if (job.type === "redeploy") {
|
||||||
await rebuildRemoteCompose({
|
await rebuildCompose({
|
||||||
composeId: job.composeId,
|
composeId: job.composeId,
|
||||||
titleLog: job.titleLog || "Rebuild deployment",
|
titleLog: job.titleLog || "Rebuild deployment",
|
||||||
descriptionLog: job.descriptionLog || "",
|
descriptionLog: job.descriptionLog || "",
|
||||||
});
|
});
|
||||||
} else if (job.type === "deploy") {
|
} else if (job.type === "deploy") {
|
||||||
await deployRemoteCompose({
|
await deployCompose({
|
||||||
composeId: job.composeId,
|
composeId: job.composeId,
|
||||||
titleLog: job.titleLog || "Manual deployment",
|
titleLog: job.titleLog || "Manual deployment",
|
||||||
descriptionLog: job.descriptionLog || "",
|
descriptionLog: job.descriptionLog || "",
|
||||||
@@ -54,8 +55,15 @@ export const deploy = async (job: DeployJob) => {
|
|||||||
previewStatus: "running",
|
previewStatus: "running",
|
||||||
});
|
});
|
||||||
if (job.server) {
|
if (job.server) {
|
||||||
if (job.type === "deploy") {
|
if (job.type === "redeploy") {
|
||||||
await deployRemotePreviewApplication({
|
await rebuildPreviewApplication({
|
||||||
|
applicationId: job.applicationId,
|
||||||
|
titleLog: job.titleLog || "Rebuild Preview Deployment",
|
||||||
|
descriptionLog: job.descriptionLog || "",
|
||||||
|
previewDeploymentId: job.previewDeploymentId,
|
||||||
|
});
|
||||||
|
} else if (job.type === "deploy") {
|
||||||
|
await deployPreviewApplication({
|
||||||
applicationId: job.applicationId,
|
applicationId: job.applicationId,
|
||||||
titleLog: job.titleLog || "Preview Deployment",
|
titleLog: job.titleLog || "Preview Deployment",
|
||||||
descriptionLog: job.descriptionLog || "",
|
descriptionLog: job.descriptionLog || "",
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy"
|
|
||||||
PORT=3000
|
PORT=3000
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
@@ -1 +0,0 @@
|
|||||||
20.16.0
|
|
||||||
243
apps/dokploy/__test__/cluster/upload.test.ts
Normal file
243
apps/dokploy/__test__/cluster/upload.test.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import type { Registry } from "@dokploy/server";
|
||||||
|
import { getRegistryTag } from "@dokploy/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("getRegistryTag", () => {
|
||||||
|
// Helper to create a mock registry
|
||||||
|
const createMockRegistry = (overrides: Partial<Registry> = {}): Registry => {
|
||||||
|
return {
|
||||||
|
registryId: "test-registry-id",
|
||||||
|
registryName: "Test Registry",
|
||||||
|
username: "myuser",
|
||||||
|
password: "test-password",
|
||||||
|
registryUrl: "docker.io",
|
||||||
|
registryType: "cloud",
|
||||||
|
imagePrefix: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
organizationId: "test-org-id",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("with username (no imagePrefix)", () => {
|
||||||
|
it("should handle simple image name without tag", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("docker.io/myuser/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with tag", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "nginx:latest");
|
||||||
|
expect(result).toBe("docker.io/myuser/nginx:latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with username already present (no duplication)", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
||||||
|
// Should not duplicate username
|
||||||
|
expect(result).toBe("docker.io/myuser/myprivaterepo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with username and tag already present", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "myuser/myprivaterepo:latest");
|
||||||
|
// Should not duplicate username
|
||||||
|
expect(result).toBe("docker.io/myuser/myprivaterepo:latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle complex image name with username", () => {
|
||||||
|
const registry = createMockRegistry({ username: "siumauricio" });
|
||||||
|
const result = getRegistryTag(
|
||||||
|
registry,
|
||||||
|
"siumauricio/app-parse-multi-byte-port-e32uh7",
|
||||||
|
);
|
||||||
|
// Should not duplicate username
|
||||||
|
expect(result).toBe(
|
||||||
|
"docker.io/siumauricio/app-parse-multi-byte-port-e32uh7",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with different username (should not duplicate)", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "otheruser/myprivaterepo");
|
||||||
|
expect(result).toBe("docker.io/myuser/myprivaterepo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with full registry URL (no username)", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "docker.io/nginx");
|
||||||
|
// Should add username since imageName doesn't have one
|
||||||
|
expect(result).toBe("docker.io/myuser/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with custom registry URL and username", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "ghcr.io/myuser/repo");
|
||||||
|
// Should not duplicate username even if registry URL is different
|
||||||
|
expect(result).toBe("docker.io/myuser/repo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with custom registry URL (different username)", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "ghcr.io/otheruser/repo");
|
||||||
|
// Should use registry username, not the one in imageName
|
||||||
|
expect(result).toBe("docker.io/myuser/repo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("with imagePrefix", () => {
|
||||||
|
it("should use imagePrefix instead of username", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("docker.io/myorg/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use imagePrefix with image tag", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx:latest");
|
||||||
|
expect(result).toBe("docker.io/myorg/nginx:latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle imagePrefix with username already in image name", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
||||||
|
expect(result).toBe("docker.io/myorg/myprivaterepo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle imagePrefix matching image name prefix", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "myorg/myprivaterepo");
|
||||||
|
// Should not duplicate prefix
|
||||||
|
expect(result).toBe("docker.io/myorg/myprivaterepo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("without registryUrl", () => {
|
||||||
|
it("should work without registryUrl", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
registryUrl: "",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("myuser/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should work without registryUrl with imagePrefix", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
registryUrl: "",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("myorg/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle username already present without registryUrl", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
registryUrl: "",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
||||||
|
// Should not duplicate username
|
||||||
|
expect(result).toBe("myuser/myprivaterepo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("with custom registryUrl", () => {
|
||||||
|
it("should handle custom registry URL", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
registryUrl: "ghcr.io",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("ghcr.io/myuser/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle custom registry URL with imagePrefix", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
registryUrl: "ghcr.io",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("ghcr.io/myorg/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle custom registry URL with username already present", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
registryUrl: "ghcr.io",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
||||||
|
// Should not duplicate username
|
||||||
|
expect(result).toBe("ghcr.io/myuser/myprivaterepo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("should handle empty image name", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "");
|
||||||
|
expect(result).toBe("docker.io/myuser/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with multiple slashes", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "org/suborg/repo");
|
||||||
|
expect(result).toBe("docker.io/myuser/repo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with username at different position", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "org/myuser/repo");
|
||||||
|
expect(result).toBe("docker.io/myuser/repo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("special characters in username", () => {
|
||||||
|
it("should handle Harbor robot account username with $ (e.g. robot$library+dokploy)", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "robot$library+dokploy",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("docker.io/robot$library+dokploy/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle username with $ and other special characters", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "robot$test+app",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "myapp:latest");
|
||||||
|
expect(result).toBe("docker.io/robot$test+app/myapp:latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle username with multiple $ symbols", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "user$name$test",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "app");
|
||||||
|
expect(result).toBe("docker.io/user$name$test/app");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle username with + and - symbols", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "robot+test-user",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx:latest");
|
||||||
|
expect(result).toBe("docker.io/robot+test-user/nginx:latest");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
215
apps/dokploy/__test__/compose/domain/host-rule-format.test.ts
Normal file
215
apps/dokploy/__test__/compose/domain/host-rule-format.test.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import type { Domain } from "@dokploy/server";
|
||||||
|
import { createDomainLabels } from "@dokploy/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parse, stringify } from "yaml";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for Traefik Host rule label format.
|
||||||
|
*
|
||||||
|
* These tests verify that the Host rule is generated with the correct format:
|
||||||
|
* - Host(`domain.com`) - with opening and closing parentheses
|
||||||
|
* - Host(`domain.com`) && PathPrefix(`/path`) - for path-based routing
|
||||||
|
*
|
||||||
|
* Issue: https://github.com/Dokploy/dokploy/issues/3161
|
||||||
|
* The bug caused Host rules to be malformed as Host`domain.com`)
|
||||||
|
* (missing opening parenthesis) which broke all domain routing.
|
||||||
|
*/
|
||||||
|
describe("Host rule format regression tests", () => {
|
||||||
|
const baseDomain: Domain = {
|
||||||
|
host: "example.com",
|
||||||
|
port: 8080,
|
||||||
|
https: false,
|
||||||
|
uniqueConfigKey: 1,
|
||||||
|
customCertResolver: null,
|
||||||
|
certificateType: "none",
|
||||||
|
applicationId: "",
|
||||||
|
composeId: "",
|
||||||
|
domainType: "compose",
|
||||||
|
serviceName: "test-app",
|
||||||
|
domainId: "",
|
||||||
|
path: "/",
|
||||||
|
createdAt: "",
|
||||||
|
previewDeploymentId: "",
|
||||||
|
internalPath: "/",
|
||||||
|
stripPath: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Host rule format validation", () => {
|
||||||
|
it("should generate Host rule with correct parentheses format", async () => {
|
||||||
|
const labels = await createDomainLabels("test-app", baseDomain, "web");
|
||||||
|
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||||
|
|
||||||
|
expect(ruleLabel).toBeDefined();
|
||||||
|
// Verify exact format: Host(`domain`)
|
||||||
|
expect(ruleLabel).toMatch(/Host\(`[^`]+`\)/);
|
||||||
|
// Ensure opening parenthesis is present after Host
|
||||||
|
expect(ruleLabel).toContain("Host(`example.com`)");
|
||||||
|
// Ensure it does NOT have the malformed format
|
||||||
|
expect(ruleLabel).not.toMatch(/Host`[^`]+`\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate PathPrefix with correct parentheses format", async () => {
|
||||||
|
const labels = await createDomainLabels(
|
||||||
|
"test-app",
|
||||||
|
{ ...baseDomain, path: "/api" },
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||||
|
|
||||||
|
expect(ruleLabel).toBeDefined();
|
||||||
|
// Verify PathPrefix format
|
||||||
|
expect(ruleLabel).toMatch(/PathPrefix\(`[^`]+`\)/);
|
||||||
|
expect(ruleLabel).toContain("PathPrefix(`/api`)");
|
||||||
|
// Ensure opening parenthesis is present
|
||||||
|
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate combined Host and PathPrefix with correct format", async () => {
|
||||||
|
const labels = await createDomainLabels(
|
||||||
|
"test-app",
|
||||||
|
{ ...baseDomain, path: "/api/v1" },
|
||||||
|
"websecure",
|
||||||
|
);
|
||||||
|
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||||
|
|
||||||
|
expect(ruleLabel).toBeDefined();
|
||||||
|
expect(ruleLabel).toBe(
|
||||||
|
"traefik.http.routers.test-app-1-websecure.rule=Host(`example.com`) && PathPrefix(`/api/v1`)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("YAML serialization preserves Host rule format", () => {
|
||||||
|
it("should preserve Host rule format through YAML stringify/parse", async () => {
|
||||||
|
const labels = await createDomainLabels("test-app", baseDomain, "web");
|
||||||
|
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||||
|
|
||||||
|
// Simulate compose file structure
|
||||||
|
const composeSpec = {
|
||||||
|
services: {
|
||||||
|
myapp: {
|
||||||
|
image: "nginx",
|
||||||
|
labels: labels,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stringify to YAML
|
||||||
|
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
|
||||||
|
|
||||||
|
// Parse back
|
||||||
|
const parsed = parse(yamlOutput) as typeof composeSpec;
|
||||||
|
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
|
||||||
|
l.includes(".rule="),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify format is preserved
|
||||||
|
expect(parsedRuleLabel).toBe(ruleLabel);
|
||||||
|
expect(parsedRuleLabel).toContain("Host(`example.com`)");
|
||||||
|
expect(parsedRuleLabel).not.toMatch(/Host`[^`]+`\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve complex rule format through YAML serialization", async () => {
|
||||||
|
const labels = await createDomainLabels(
|
||||||
|
"test-app",
|
||||||
|
{ ...baseDomain, path: "/api", https: true },
|
||||||
|
"websecure",
|
||||||
|
);
|
||||||
|
|
||||||
|
const composeSpec = {
|
||||||
|
services: {
|
||||||
|
myapp: {
|
||||||
|
labels: labels,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
|
||||||
|
const parsed = parse(yamlOutput) as typeof composeSpec;
|
||||||
|
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
|
||||||
|
l.includes(".rule="),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsedRuleLabel).toContain(
|
||||||
|
"Host(`example.com`) && PathPrefix(`/api`)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Edge cases for domain names", () => {
|
||||||
|
const domainCases = [
|
||||||
|
{ name: "simple domain", host: "example.com" },
|
||||||
|
{ name: "subdomain", host: "app.example.com" },
|
||||||
|
{ name: "deep subdomain", host: "api.v1.app.example.com" },
|
||||||
|
{ name: "numeric domain", host: "123.example.com" },
|
||||||
|
{ name: "hyphenated domain", host: "my-app.example-host.com" },
|
||||||
|
{ name: "localhost", host: "localhost" },
|
||||||
|
{ name: "IP address style", host: "192.168.1.100" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, host } of domainCases) {
|
||||||
|
it(`should generate correct Host rule for ${name}: ${host}`, async () => {
|
||||||
|
const labels = await createDomainLabels(
|
||||||
|
"test-app",
|
||||||
|
{ ...baseDomain, host },
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||||
|
|
||||||
|
expect(ruleLabel).toBeDefined();
|
||||||
|
expect(ruleLabel).toContain(`Host(\`${host}\`)`);
|
||||||
|
// Verify parenthesis is present
|
||||||
|
expect(ruleLabel).toMatch(
|
||||||
|
new RegExp(`Host\\(\\\`${host.replace(/\./g, "\\.")}\\\`\\)`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Multiple domains scenario", () => {
|
||||||
|
it("should generate correct format for both web and websecure entrypoints", async () => {
|
||||||
|
const webLabels = await createDomainLabels("test-app", baseDomain, "web");
|
||||||
|
const websecureLabels = await createDomainLabels(
|
||||||
|
"test-app",
|
||||||
|
baseDomain,
|
||||||
|
"websecure",
|
||||||
|
);
|
||||||
|
|
||||||
|
const webRule = webLabels.find((l) => l.includes(".rule="));
|
||||||
|
const websecureRule = websecureLabels.find((l) => l.includes(".rule="));
|
||||||
|
|
||||||
|
// Both should have correct format
|
||||||
|
expect(webRule).toContain("Host(`example.com`)");
|
||||||
|
expect(websecureRule).toContain("Host(`example.com`)");
|
||||||
|
|
||||||
|
// Neither should have malformed format
|
||||||
|
expect(webRule).not.toMatch(/Host`[^`]+`\)/);
|
||||||
|
expect(websecureRule).not.toMatch(/Host`[^`]+`\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Special characters in paths", () => {
|
||||||
|
const pathCases = [
|
||||||
|
{ name: "simple path", path: "/api" },
|
||||||
|
{ name: "nested path", path: "/api/v1/users" },
|
||||||
|
{ name: "path with hyphen", path: "/api-v1" },
|
||||||
|
{ name: "path with underscore", path: "/api_v1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, path } of pathCases) {
|
||||||
|
it(`should generate correct PathPrefix for ${name}: ${path}`, async () => {
|
||||||
|
const labels = await createDomainLabels(
|
||||||
|
"test-app",
|
||||||
|
{ ...baseDomain, path },
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||||
|
|
||||||
|
expect(ruleLabel).toBeDefined();
|
||||||
|
expect(ruleLabel).toContain(`PathPrefix(\`${path}\`)`);
|
||||||
|
// Verify parenthesis is present
|
||||||
|
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,21 +4,30 @@ import { describe, expect, it } from "vitest";
|
|||||||
describe("addDokployNetworkToService", () => {
|
describe("addDokployNetworkToService", () => {
|
||||||
it("should add network to an empty array", () => {
|
it("should add network to an empty array", () => {
|
||||||
const result = addDokployNetworkToService([]);
|
const result = addDokployNetworkToService([]);
|
||||||
expect(result).toEqual(["dokploy-network"]);
|
expect(result).toEqual(["dokploy-network", "default"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not add duplicate network to an array", () => {
|
it("should not add duplicate network to an array", () => {
|
||||||
const result = addDokployNetworkToService(["dokploy-network"]);
|
const result = addDokployNetworkToService(["dokploy-network"]);
|
||||||
expect(result).toEqual(["dokploy-network"]);
|
expect(result).toEqual(["dokploy-network", "default"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should add network to an existing array with other networks", () => {
|
it("should add network to an existing array with other networks", () => {
|
||||||
const result = addDokployNetworkToService(["other-network"]);
|
const result = addDokployNetworkToService(["other-network"]);
|
||||||
expect(result).toEqual(["other-network", "dokploy-network"]);
|
expect(result).toEqual(["other-network", "dokploy-network", "default"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should add network to an object if networks is an object", () => {
|
it("should add network to an object if networks is an object", () => {
|
||||||
const result = addDokployNetworkToService({ "other-network": {} });
|
const result = addDokployNetworkToService({ "other-network": {} });
|
||||||
expect(result).toEqual({ "other-network": {}, "dokploy-network": {} });
|
expect(result).toEqual({
|
||||||
|
"other-network": {},
|
||||||
|
"dokploy-network": {},
|
||||||
|
default: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not duplicate default network when already present", () => {
|
||||||
|
const result = addDokployNetworkToService(["default", "dokploy-network"]);
|
||||||
|
expect(result).toEqual(["default", "dokploy-network"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
287
apps/dokploy/__test__/deploy/application.command.test.ts
Normal file
287
apps/dokploy/__test__/deploy/application.command.test.ts
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import * as adminService from "@dokploy/server/services/admin";
|
||||||
|
import * as applicationService from "@dokploy/server/services/application";
|
||||||
|
import { deployApplication } from "@dokploy/server/services/application";
|
||||||
|
import * as deploymentService from "@dokploy/server/services/deployment";
|
||||||
|
import * as builders from "@dokploy/server/utils/builders";
|
||||||
|
import * as notifications from "@dokploy/server/utils/notifications/build-success";
|
||||||
|
import * as execProcess from "@dokploy/server/utils/process/execAsync";
|
||||||
|
import * as gitProvider from "@dokploy/server/utils/providers/git";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/db", () => {
|
||||||
|
const createChainableMock = (): any => {
|
||||||
|
const chain = {
|
||||||
|
set: vi.fn(() => chain),
|
||||||
|
where: vi.fn(() => chain),
|
||||||
|
returning: vi.fn().mockResolvedValue([{}] as any),
|
||||||
|
from: vi.fn(() => chain),
|
||||||
|
innerJoin: vi.fn(() => chain),
|
||||||
|
then: (resolve: (v: any) => void) => {
|
||||||
|
resolve([]);
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
return chain;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
db: {
|
||||||
|
select: vi.fn(() => createChainableMock()),
|
||||||
|
insert: vi.fn(),
|
||||||
|
update: vi.fn(() => createChainableMock()),
|
||||||
|
delete: vi.fn(),
|
||||||
|
query: {
|
||||||
|
applications: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
patch: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
member: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/application", async () => {
|
||||||
|
const actual = await vi.importActual<
|
||||||
|
typeof import("@dokploy/server/services/application")
|
||||||
|
>("@dokploy/server/services/application");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
findApplicationById: vi.fn(),
|
||||||
|
updateApplicationStatus: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/admin", () => ({
|
||||||
|
getDokployUrl: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/deployment", () => ({
|
||||||
|
createDeployment: vi.fn(),
|
||||||
|
updateDeploymentStatus: vi.fn(),
|
||||||
|
updateDeployment: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/providers/git", async () => {
|
||||||
|
const actual = await vi.importActual<
|
||||||
|
typeof import("@dokploy/server/utils/providers/git")
|
||||||
|
>("@dokploy/server/utils/providers/git");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getGitCommitInfo: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||||
|
execAsync: vi.fn(),
|
||||||
|
ExecError: class ExecError extends Error {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/builders", async () => {
|
||||||
|
const actual = await vi.importActual<
|
||||||
|
typeof import("@dokploy/server/utils/builders")
|
||||||
|
>("@dokploy/server/utils/builders");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
mechanizeDockerContainer: vi.fn(),
|
||||||
|
getBuildCommand: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
|
||||||
|
sendBuildSuccessNotifications: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
|
||||||
|
sendBuildErrorNotifications: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/rollbacks", () => ({
|
||||||
|
createRollback: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { db } from "@dokploy/server/db";
|
||||||
|
import { cloneGitRepository } from "@dokploy/server/utils/providers/git";
|
||||||
|
|
||||||
|
const createMockApplication = (overrides = {}) => ({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
name: "Test App",
|
||||||
|
appName: "test-app",
|
||||||
|
sourceType: "git" as const,
|
||||||
|
customGitUrl: "https://github.com/Dokploy/examples.git",
|
||||||
|
customGitBranch: "main",
|
||||||
|
customGitSSHKeyId: null,
|
||||||
|
buildType: "nixpacks" as const,
|
||||||
|
buildPath: "/astro",
|
||||||
|
env: "NODE_ENV=production",
|
||||||
|
serverId: null,
|
||||||
|
rollbackActive: false,
|
||||||
|
enableSubmodules: false,
|
||||||
|
environmentId: "env-id",
|
||||||
|
environment: {
|
||||||
|
projectId: "project-id",
|
||||||
|
env: "",
|
||||||
|
name: "production",
|
||||||
|
project: {
|
||||||
|
name: "Test Project",
|
||||||
|
organizationId: "org-id",
|
||||||
|
env: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
domains: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMockDeployment = () => ({
|
||||||
|
deploymentId: "deployment-id",
|
||||||
|
logPath: "/tmp/test-deployment.log",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deployApplication - Command Generation Tests", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||||
|
createMockApplication() as any,
|
||||||
|
);
|
||||||
|
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||||
|
createMockApplication() as any,
|
||||||
|
);
|
||||||
|
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
|
||||||
|
"http://localhost:3000",
|
||||||
|
);
|
||||||
|
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
|
||||||
|
createMockDeployment() as any,
|
||||||
|
);
|
||||||
|
vi.mocked(execProcess.execAsync).mockResolvedValue({
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
} as any);
|
||||||
|
vi.mocked(builders.mechanizeDockerContainer).mockResolvedValue(
|
||||||
|
undefined as any,
|
||||||
|
);
|
||||||
|
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
|
||||||
|
undefined as any,
|
||||||
|
);
|
||||||
|
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
|
||||||
|
{} as any,
|
||||||
|
);
|
||||||
|
vi.mocked(notifications.sendBuildSuccessNotifications).mockResolvedValue(
|
||||||
|
undefined as any,
|
||||||
|
);
|
||||||
|
vi.mocked(gitProvider.getGitCommitInfo).mockResolvedValue({
|
||||||
|
message: "test commit",
|
||||||
|
hash: "abc123",
|
||||||
|
});
|
||||||
|
vi.mocked(deploymentService.updateDeployment).mockResolvedValue({} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate correct git clone command for astro example", async () => {
|
||||||
|
const app = createMockApplication();
|
||||||
|
const command = await cloneGitRepository(app);
|
||||||
|
console.log(command);
|
||||||
|
|
||||||
|
expect(command).toContain("https://github.com/Dokploy/examples.git");
|
||||||
|
expect(command).not.toContain("--recurse-submodules");
|
||||||
|
expect(command).toContain("--branch main");
|
||||||
|
expect(command).toContain("--depth 1");
|
||||||
|
expect(command).toContain("git clone");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate git clone with submodules when enabled", async () => {
|
||||||
|
const app = createMockApplication({ enableSubmodules: true });
|
||||||
|
const command = await cloneGitRepository(app);
|
||||||
|
|
||||||
|
expect(command).toContain("--recurse-submodules");
|
||||||
|
expect(command).toContain("https://github.com/Dokploy/examples.git");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should verify nixpacks command is called with correct app", async () => {
|
||||||
|
const mockNixpacksCommand = "nixpacks build /path/to/app --name test-app";
|
||||||
|
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
|
||||||
|
|
||||||
|
await deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Test deployment",
|
||||||
|
descriptionLog: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(builders.getBuildCommand).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
buildType: "nixpacks",
|
||||||
|
customGitUrl: "https://github.com/Dokploy/examples.git",
|
||||||
|
buildPath: "/astro",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(execProcess.execAsync).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("nixpacks build"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should verify railpack command includes correct parameters", async () => {
|
||||||
|
const mockApp = createMockApplication({ buildType: "railpack" });
|
||||||
|
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||||
|
mockApp as any,
|
||||||
|
);
|
||||||
|
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||||
|
mockApp as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockRailpackCommand = "railpack prepare /path/to/app";
|
||||||
|
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockRailpackCommand);
|
||||||
|
|
||||||
|
await deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Railpack test",
|
||||||
|
descriptionLog: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(builders.getBuildCommand).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
buildType: "railpack",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(execProcess.execAsync).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("railpack prepare"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should execute commands in correct order", async () => {
|
||||||
|
const mockNixpacksCommand = "nixpacks build";
|
||||||
|
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
|
||||||
|
|
||||||
|
await deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Test",
|
||||||
|
descriptionLog: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
|
||||||
|
expect(execCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const fullCommand = execCalls[0]?.[0];
|
||||||
|
expect(fullCommand).toContain("set -e");
|
||||||
|
expect(fullCommand).toContain("git clone");
|
||||||
|
expect(fullCommand).toContain("nixpacks build");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include log redirection in command", async () => {
|
||||||
|
const mockCommand = "nixpacks build";
|
||||||
|
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockCommand);
|
||||||
|
|
||||||
|
await deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Test",
|
||||||
|
descriptionLog: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
|
||||||
|
const fullCommand = execCalls[0]?.[0];
|
||||||
|
|
||||||
|
expect(fullCommand).toContain(">> /tmp/test-deployment.log 2>&1");
|
||||||
|
});
|
||||||
|
});
|
||||||
490
apps/dokploy/__test__/deploy/application.real.test.ts
Normal file
490
apps/dokploy/__test__/deploy/application.real.test.ts
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { ApplicationNested } from "@dokploy/server";
|
||||||
|
import { paths } from "@dokploy/server/constants";
|
||||||
|
import { execAsync } from "@dokploy/server/utils/process/execAsync";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const REAL_TEST_TIMEOUT = 180000; // 3 minutes
|
||||||
|
|
||||||
|
// Mock ONLY database and notifications
|
||||||
|
vi.mock("@dokploy/server/db", () => {
|
||||||
|
const createChainableMock = (): any => {
|
||||||
|
const chain: any = {
|
||||||
|
set: vi.fn(() => chain),
|
||||||
|
where: vi.fn(() => chain),
|
||||||
|
returning: vi.fn().mockResolvedValue([{}]),
|
||||||
|
from: vi.fn(() => chain),
|
||||||
|
innerJoin: vi.fn(() => chain),
|
||||||
|
then: (resolve: (v: any) => void) => {
|
||||||
|
resolve([]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return chain;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
db: {
|
||||||
|
select: vi.fn(() => createChainableMock()),
|
||||||
|
insert: vi.fn(),
|
||||||
|
update: vi.fn(() => createChainableMock()),
|
||||||
|
delete: vi.fn(),
|
||||||
|
query: {
|
||||||
|
applications: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
patch: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
member: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/application", async () => {
|
||||||
|
const actual = await vi.importActual<
|
||||||
|
typeof import("@dokploy/server/services/application")
|
||||||
|
>("@dokploy/server/services/application");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
findApplicationById: vi.fn(),
|
||||||
|
updateApplicationStatus: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/admin", () => ({
|
||||||
|
getDokployUrl: vi.fn().mockResolvedValue("http://localhost:3000"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/deployment", () => ({
|
||||||
|
createDeployment: vi.fn(),
|
||||||
|
updateDeploymentStatus: vi.fn(),
|
||||||
|
updateDeployment: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
|
||||||
|
sendBuildSuccessNotifications: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
|
||||||
|
sendBuildErrorNotifications: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/rollbacks", () => ({
|
||||||
|
createRollback: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// NOT mocked (executed for real):
|
||||||
|
// - execAsync
|
||||||
|
// - cloneGitRepository
|
||||||
|
// - getBuildCommand
|
||||||
|
// - mechanizeDockerContainer (requires Docker Swarm)
|
||||||
|
|
||||||
|
import { db } from "@dokploy/server/db";
|
||||||
|
import * as adminService from "@dokploy/server/services/admin";
|
||||||
|
import * as applicationService from "@dokploy/server/services/application";
|
||||||
|
import { deployApplication } from "@dokploy/server/services/application";
|
||||||
|
import * as deploymentService from "@dokploy/server/services/deployment";
|
||||||
|
|
||||||
|
const createMockApplication = (
|
||||||
|
overrides: Partial<ApplicationNested> = {},
|
||||||
|
): ApplicationNested =>
|
||||||
|
({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
name: "Real Test App",
|
||||||
|
appName: `real-test-${Date.now()}`,
|
||||||
|
sourceType: "git" as const,
|
||||||
|
customGitUrl: "https://github.com/Dokploy/examples.git",
|
||||||
|
customGitBranch: "main",
|
||||||
|
customGitSSHKeyId: null,
|
||||||
|
customGitBuildPath: "/astro",
|
||||||
|
buildType: "nixpacks" as const,
|
||||||
|
env: "NODE_ENV=production",
|
||||||
|
serverId: null,
|
||||||
|
rollbackActive: false,
|
||||||
|
enableSubmodules: false,
|
||||||
|
environmentId: "env-id",
|
||||||
|
environment: {
|
||||||
|
projectId: "project-id",
|
||||||
|
env: "",
|
||||||
|
name: "production",
|
||||||
|
project: {
|
||||||
|
name: "Test Project",
|
||||||
|
organizationId: "org-id",
|
||||||
|
env: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
domains: [],
|
||||||
|
mounts: [],
|
||||||
|
security: [],
|
||||||
|
redirects: [],
|
||||||
|
ports: [],
|
||||||
|
registry: null,
|
||||||
|
...overrides,
|
||||||
|
}) as ApplicationNested;
|
||||||
|
|
||||||
|
const createMockDeployment = async (appName: string) => {
|
||||||
|
const { LOGS_PATH } = paths(false); // false = local, no remote server
|
||||||
|
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||||
|
const fileName = `${appName}-${formattedDateTime}.log`;
|
||||||
|
const logFilePath = path.join(LOGS_PATH, appName, fileName);
|
||||||
|
|
||||||
|
// Actually create the log directory
|
||||||
|
await execAsync(`mkdir -p ${path.dirname(logFilePath)}`);
|
||||||
|
await execAsync(`echo "Initializing deployment" > ${logFilePath}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
deploymentId: "deployment-id",
|
||||||
|
logPath: logFilePath,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function cleanupDocker(appName: string) {
|
||||||
|
try {
|
||||||
|
await execAsync(`docker stop ${appName} 2>/dev/null || true`);
|
||||||
|
await execAsync(`docker rm ${appName} 2>/dev/null || true`);
|
||||||
|
await execAsync(`docker rmi ${appName} 2>/dev/null || true`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Docker cleanup completed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupFiles(appName: string) {
|
||||||
|
try {
|
||||||
|
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
|
||||||
|
|
||||||
|
// Clean cloned code directories
|
||||||
|
const appPath = path.join(APPLICATIONS_PATH, appName);
|
||||||
|
await execAsync(`rm -rf ${appPath} 2>/dev/null || true`);
|
||||||
|
|
||||||
|
// Clean logs for appName - removes entire folder
|
||||||
|
const logPath = path.join(LOGS_PATH, appName);
|
||||||
|
await execAsync(`rm -rf ${logPath} 2>/dev/null || true`);
|
||||||
|
|
||||||
|
console.log(`✅ Cleaned up files and logs for ${appName}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`⚠️ Error during cleanup for ${appName}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(
|
||||||
|
"deployApplication - REAL Execution Tests",
|
||||||
|
() => {
|
||||||
|
let currentAppName: string;
|
||||||
|
let currentDeployment: any;
|
||||||
|
const allTestAppNames: string[] = [];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
currentAppName = `real-test-${Date.now()}`;
|
||||||
|
currentDeployment = await createMockDeployment(currentAppName);
|
||||||
|
allTestAppNames.push(currentAppName);
|
||||||
|
|
||||||
|
const mockApp = createMockApplication({ appName: currentAppName });
|
||||||
|
|
||||||
|
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||||
|
mockApp as any,
|
||||||
|
);
|
||||||
|
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||||
|
mockApp as any,
|
||||||
|
);
|
||||||
|
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
|
||||||
|
"http://localhost:3000",
|
||||||
|
);
|
||||||
|
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
|
||||||
|
currentDeployment as any,
|
||||||
|
);
|
||||||
|
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
|
||||||
|
undefined as any,
|
||||||
|
);
|
||||||
|
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
|
||||||
|
{} as any,
|
||||||
|
);
|
||||||
|
vi.mocked(deploymentService.updateDeployment).mockResolvedValue(
|
||||||
|
{} as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// ALWAYS cleanup, even if test failed or passed
|
||||||
|
console.log(`\n🧹 Cleaning up test: ${currentAppName}`);
|
||||||
|
|
||||||
|
// Clean current appName
|
||||||
|
try {
|
||||||
|
await cleanupDocker(currentAppName);
|
||||||
|
await cleanupFiles(currentAppName);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("⚠️ Error cleaning current app:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean ALL test folders just in case
|
||||||
|
try {
|
||||||
|
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
|
||||||
|
await execAsync(`rm -rf ${LOGS_PATH}/real-* 2>/dev/null || true`);
|
||||||
|
await execAsync(
|
||||||
|
`rm -rf ${APPLICATIONS_PATH}/real-* 2>/dev/null || true`,
|
||||||
|
);
|
||||||
|
console.log("✅ Cleaned up all test artifacts");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("⚠️ Error cleaning all artifacts:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Cleanup completed\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
"should REALLY clone git repo and build with nixpacks",
|
||||||
|
async () => {
|
||||||
|
console.log(`\n🚀 Testing real deployment with app: ${currentAppName}`);
|
||||||
|
|
||||||
|
const result = await deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Real Nixpacks Test",
|
||||||
|
descriptionLog: "Testing real execution",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
// Verify that Docker image was actually created
|
||||||
|
const { stdout: dockerImages } = await execAsync(
|
||||||
|
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||||
|
);
|
||||||
|
console.log("dockerImages", dockerImages);
|
||||||
|
expect(dockerImages.trim()).toBe(currentAppName);
|
||||||
|
console.log(`✅ Docker image created: ${currentAppName}`);
|
||||||
|
|
||||||
|
// Verify log exists and has content
|
||||||
|
expect(existsSync(currentDeployment.logPath)).toBe(true);
|
||||||
|
const { stdout: logContent } = await execAsync(
|
||||||
|
`cat ${currentDeployment.logPath}`,
|
||||||
|
);
|
||||||
|
expect(logContent).toContain("Cloning");
|
||||||
|
expect(logContent).toContain("nixpacks");
|
||||||
|
console.log(`✅ Build log created with ${logContent.length} chars`);
|
||||||
|
|
||||||
|
// Verify update functions were called
|
||||||
|
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
|
||||||
|
"deployment-id",
|
||||||
|
"done",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
REAL_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it.skip(
|
||||||
|
"should REALLY build with railpack (SKIPPED: requires special permissions)",
|
||||||
|
async () => {
|
||||||
|
const railpackAppName = `real-railpack-${Date.now()}`;
|
||||||
|
const railpackApp = createMockApplication({
|
||||||
|
appName: railpackAppName,
|
||||||
|
buildType: "railpack",
|
||||||
|
railpackVersion: "3",
|
||||||
|
});
|
||||||
|
currentAppName = railpackAppName;
|
||||||
|
allTestAppNames.push(railpackAppName);
|
||||||
|
|
||||||
|
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||||
|
railpackApp as any,
|
||||||
|
);
|
||||||
|
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||||
|
railpackApp as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\n🚀 Testing real railpack deployment: ${currentAppName}`);
|
||||||
|
|
||||||
|
const result = await deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Real Railpack Test",
|
||||||
|
descriptionLog: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
const { stdout: dockerImages } = await execAsync(
|
||||||
|
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||||
|
);
|
||||||
|
expect(dockerImages.trim()).toBe(currentAppName);
|
||||||
|
console.log(`✅ Railpack image created: ${currentAppName}`);
|
||||||
|
|
||||||
|
const { stdout: logContent } = await execAsync(
|
||||||
|
`cat ${currentDeployment.logPath}`,
|
||||||
|
);
|
||||||
|
expect(logContent).toContain("railpack");
|
||||||
|
console.log("✅ Railpack build completed");
|
||||||
|
},
|
||||||
|
REAL_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"should handle REAL git clone errors",
|
||||||
|
async () => {
|
||||||
|
const errorAppName = `real-error-${Date.now()}`;
|
||||||
|
const errorApp = createMockApplication({
|
||||||
|
appName: errorAppName,
|
||||||
|
customGitUrl:
|
||||||
|
"https://github.com/invalid/nonexistent-repo-123456.git",
|
||||||
|
});
|
||||||
|
currentAppName = errorAppName;
|
||||||
|
allTestAppNames.push(errorAppName);
|
||||||
|
|
||||||
|
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||||
|
errorApp as any,
|
||||||
|
);
|
||||||
|
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||||
|
errorApp as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\n🚀 Testing real error handling: ${currentAppName}`);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Real Error Test",
|
||||||
|
descriptionLog: "",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
// Verify error status was called
|
||||||
|
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
|
||||||
|
"deployment-id",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify log contains error
|
||||||
|
const { stdout: logContent } = await execAsync(
|
||||||
|
`cat ${currentDeployment.logPath}`,
|
||||||
|
);
|
||||||
|
expect(logContent.toLowerCase()).toContain("error");
|
||||||
|
console.log("✅ Error handling verified");
|
||||||
|
},
|
||||||
|
REAL_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"should REALLY clone with submodules when enabled",
|
||||||
|
async () => {
|
||||||
|
const submodulesAppName = `real-submodules-${Date.now()}`;
|
||||||
|
const submodulesApp = createMockApplication({
|
||||||
|
appName: submodulesAppName,
|
||||||
|
enableSubmodules: true,
|
||||||
|
});
|
||||||
|
currentAppName = submodulesAppName;
|
||||||
|
allTestAppNames.push(submodulesAppName);
|
||||||
|
|
||||||
|
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||||
|
submodulesApp as any,
|
||||||
|
);
|
||||||
|
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||||
|
submodulesApp as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\n🚀 Testing real submodules support: ${currentAppName}`);
|
||||||
|
|
||||||
|
const result = await deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Real Submodules Test",
|
||||||
|
descriptionLog: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
// Verify deployment completed successfully
|
||||||
|
const { stdout: logContent } = await execAsync(
|
||||||
|
`cat ${currentDeployment.logPath}`,
|
||||||
|
);
|
||||||
|
expect(logContent).toContain("Cloning");
|
||||||
|
expect(logContent.length).toBeGreaterThan(100);
|
||||||
|
console.log("✅ Submodules deployment completed");
|
||||||
|
|
||||||
|
// Verify image
|
||||||
|
const { stdout: dockerImages } = await execAsync(
|
||||||
|
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||||
|
);
|
||||||
|
expect(dockerImages.trim()).toBe(currentAppName);
|
||||||
|
},
|
||||||
|
REAL_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"should verify REAL commit info extraction",
|
||||||
|
async () => {
|
||||||
|
console.log(`\n🚀 Testing real commit info: ${currentAppName}`);
|
||||||
|
|
||||||
|
await deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Real Commit Test",
|
||||||
|
descriptionLog: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify updateDeployment was called with commit info
|
||||||
|
expect(deploymentService.updateDeployment).toHaveBeenCalled();
|
||||||
|
const updateCall = vi.mocked(deploymentService.updateDeployment).mock
|
||||||
|
.calls[0];
|
||||||
|
|
||||||
|
// Real commit info should have title and hash
|
||||||
|
expect(updateCall?.[1]).toHaveProperty("title");
|
||||||
|
expect(updateCall?.[1]).toHaveProperty("description");
|
||||||
|
expect(updateCall?.[1]?.description).toContain("Commit:");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✅ Real commit extracted: ${updateCall?.[1]?.title?.substring(0, 50)}...`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
REAL_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"should REALLY build with Dockerfile",
|
||||||
|
async () => {
|
||||||
|
const dockerfileAppName = `real-dockerfile-${Date.now()}`;
|
||||||
|
const dockerfileApp = createMockApplication({
|
||||||
|
appName: dockerfileAppName,
|
||||||
|
buildType: "dockerfile",
|
||||||
|
customGitBuildPath: "/deno",
|
||||||
|
dockerfile: "Dockerfile",
|
||||||
|
});
|
||||||
|
currentAppName = dockerfileAppName;
|
||||||
|
allTestAppNames.push(dockerfileAppName);
|
||||||
|
|
||||||
|
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||||
|
dockerfileApp as any,
|
||||||
|
);
|
||||||
|
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||||
|
dockerfileApp as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\n🚀 Testing real Dockerfile build: ${currentAppName}`);
|
||||||
|
|
||||||
|
const result = await deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Real Dockerfile Test",
|
||||||
|
descriptionLog: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
// Verify log
|
||||||
|
const { stdout: logContent } = await execAsync(
|
||||||
|
`cat ${currentDeployment.logPath}`,
|
||||||
|
);
|
||||||
|
expect(logContent).toContain("Building");
|
||||||
|
expect(logContent).toContain(dockerfileAppName);
|
||||||
|
console.log("✅ Dockerfile build log verified");
|
||||||
|
|
||||||
|
// Verify image
|
||||||
|
const { stdout: dockerImages } = await execAsync(
|
||||||
|
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||||
|
);
|
||||||
|
console.log("dockerImages", dockerImages);
|
||||||
|
expect(dockerImages.trim()).toBe(currentAppName);
|
||||||
|
console.log(`✅ Docker image created: ${currentAppName}`);
|
||||||
|
},
|
||||||
|
REAL_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
REAL_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { extractCommitMessage } from "@/pages/api/deploy/[refreshToken]";
|
import {
|
||||||
|
extractCommitMessage,
|
||||||
|
extractImageName,
|
||||||
|
extractImageTag,
|
||||||
|
extractImageTagFromRequest,
|
||||||
|
} from "@/pages/api/deploy/[refreshToken]";
|
||||||
|
|
||||||
describe("GitHub Webhook Skip CI", () => {
|
describe("GitHub Webhook Skip CI", () => {
|
||||||
const mockGithubHeaders = {
|
const mockGithubHeaders = {
|
||||||
@@ -78,6 +83,14 @@ 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", () => {
|
||||||
@@ -94,5 +107,313 @@ 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GitHub Packages Docker Image Tag Extraction", () => {
|
||||||
|
it("should extract tag from container_metadata", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
version: "sha256:abc123...",
|
||||||
|
container_metadata: {
|
||||||
|
tag: {
|
||||||
|
name: "v1.0.0",
|
||||||
|
digest: "sha256:abc123...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
package_url: "ghcr.io/owner/repo:v1.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tag = extractImageTagFromRequest(headers, body);
|
||||||
|
expect(tag).toBe("v1.0.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract tag from package_url when container_metadata tag matches version", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
version: "sha256:abc123...",
|
||||||
|
container_metadata: {
|
||||||
|
tag: {
|
||||||
|
name: "sha256:abc123...",
|
||||||
|
digest: "sha256:abc123...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
package_url: "ghcr.io/owner/repo:latest",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tag = extractImageTagFromRequest(headers, body);
|
||||||
|
expect(tag).toBe("latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract tag from package_url when container_metadata is missing", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
version: "sha256:abc123...",
|
||||||
|
package_url: "ghcr.io/owner/repo:1.2.3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tag = extractImageTagFromRequest(headers, body);
|
||||||
|
expect(tag).toBe("1.2.3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle different tag formats in package_url", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const testCases = [
|
||||||
|
{ url: "ghcr.io/owner/repo:latest", expected: "latest" },
|
||||||
|
{ url: "ghcr.io/owner/repo:v1.0.0", expected: "v1.0.0" },
|
||||||
|
{ url: "ghcr.io/owner/repo:1.2.3", expected: "1.2.3" },
|
||||||
|
{ url: "ghcr.io/owner/repo:dev", expected: "dev" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
version: "sha256:abc123...",
|
||||||
|
package_url: testCase.url,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tag = extractImageTagFromRequest(headers, body);
|
||||||
|
expect(tag).toBe(testCase.expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for non-registry_package events", () => {
|
||||||
|
const headers = { "x-github-event": "push" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
package_url: "ghcr.io/owner/repo:latest",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tag = extractImageTagFromRequest(headers, body);
|
||||||
|
expect(tag).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when package_version is missing", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tag = extractImageTagFromRequest(headers, body);
|
||||||
|
expect(tag).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when package_url has no tag", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
version: "sha256:abc123...",
|
||||||
|
package_url: "ghcr.io/owner/repo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tag = extractImageTagFromRequest(headers, body);
|
||||||
|
expect(tag).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when package_url ends with colon (no tag)", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
version: "sha256:abc123...",
|
||||||
|
package_url: "ghcr.io/owner/repo:",
|
||||||
|
container_metadata: {
|
||||||
|
tag: {
|
||||||
|
name: "",
|
||||||
|
digest: "sha256:abc123...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tag = extractImageTagFromRequest(headers, body);
|
||||||
|
expect(tag).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when tag name is empty string", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
version: "sha256:abc123...",
|
||||||
|
container_metadata: {
|
||||||
|
tag: {
|
||||||
|
name: "",
|
||||||
|
digest: "sha256:abc123...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
package_url: "ghcr.io/owner/repo:",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tag = extractImageTagFromRequest(headers, body);
|
||||||
|
expect(tag).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore tag if it matches the version (digest)", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
version: "sha256:abc123...",
|
||||||
|
container_metadata: {
|
||||||
|
tag: {
|
||||||
|
name: "sha256:abc123...",
|
||||||
|
digest: "sha256:abc123...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
package_url: "ghcr.io/owner/repo:latest",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tag = extractImageTagFromRequest(headers, body);
|
||||||
|
expect(tag).toBe("latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle registry_package commit message with package_url", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
package_url: "ghcr.io/owner/repo:latest",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = extractCommitMessage(headers, body);
|
||||||
|
expect(message).toBe("Docker GHCR image pushed: ghcr.io/owner/repo:latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle registry_package commit message when package_url is missing", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
version: "sha256:abc123...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = extractCommitMessage(headers, body);
|
||||||
|
expect(message).toBe("Docker GHCR image pushed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle registry_package commit message when package_version is missing", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = extractCommitMessage(headers, body);
|
||||||
|
expect(message).toBe("NEW COMMIT");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Docker Image Name and Tag Extraction", () => {
|
||||||
|
describe("extractImageName", () => {
|
||||||
|
it("should return image name without tag", () => {
|
||||||
|
expect(extractImageName("my-image:latest")).toBe("my-image");
|
||||||
|
expect(extractImageName("my-image:1.0.0")).toBe("my-image");
|
||||||
|
expect(extractImageName("ghcr.io/owner/repo:latest")).toBe(
|
||||||
|
"ghcr.io/owner/repo",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return full image name when no tag is present", () => {
|
||||||
|
expect(extractImageName("my-image")).toBe("my-image");
|
||||||
|
expect(extractImageName("ghcr.io/owner/repo")).toBe("ghcr.io/owner/repo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle images with port numbers correctly", () => {
|
||||||
|
expect(extractImageName("registry:5000/image:tag")).toBe(
|
||||||
|
"registry:5000/image",
|
||||||
|
);
|
||||||
|
expect(extractImageName("localhost:5000/my-app:latest")).toBe(
|
||||||
|
"localhost:5000/my-app",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle complex image paths", () => {
|
||||||
|
expect(
|
||||||
|
extractImageName("myregistryhost:5000/fedora/httpd:version1.0"),
|
||||||
|
).toBe("myregistryhost:5000/fedora/httpd");
|
||||||
|
expect(extractImageName("registry.example.com:8080/ns/app:v1.2.3")).toBe(
|
||||||
|
"registry.example.com:8080/ns/app",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for invalid inputs", () => {
|
||||||
|
expect(extractImageName(null)).toBeNull();
|
||||||
|
expect(extractImageName("")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle edge cases with multiple colons", () => {
|
||||||
|
expect(extractImageName("image:tag:extra")).toBe("image:tag");
|
||||||
|
expect(extractImageName("registry:5000:invalid")).toBe("registry:5000");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractImageTag", () => {
|
||||||
|
it("should extract tag from image with tag", () => {
|
||||||
|
expect(extractImageTag("my-image:latest")).toBe("latest");
|
||||||
|
expect(extractImageTag("my-image:1.0.0")).toBe("1.0.0");
|
||||||
|
expect(extractImageTag("ghcr.io/owner/repo:v1.2.3")).toBe("v1.2.3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 'latest' when no tag is present", () => {
|
||||||
|
expect(extractImageTag("my-image")).toBe("latest");
|
||||||
|
expect(extractImageTag("ghcr.io/owner/repo")).toBe("latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle complex image paths with tags", () => {
|
||||||
|
expect(
|
||||||
|
extractImageTag("myregistryhost:5000/fedora/httpd:version1.0"),
|
||||||
|
).toBe("version1.0");
|
||||||
|
expect(extractImageTag("registry.example.com:8080/ns/app:v1.2.3")).toBe(
|
||||||
|
"v1.2.3",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for invalid inputs", () => {
|
||||||
|
expect(extractImageTag(null)).toBeNull();
|
||||||
|
expect(extractImageTag("")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle edge cases with multiple colons", () => {
|
||||||
|
expect(extractImageTag("image:tag:extra")).toBe("extra");
|
||||||
|
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle numeric tags", () => {
|
||||||
|
expect(extractImageTag("my-image:123")).toBe("123");
|
||||||
|
expect(extractImageTag("my-image:1")).toBe("1");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
49
apps/dokploy/__test__/deploy/soft-serve.test.ts
Normal file
49
apps/dokploy/__test__/deploy/soft-serve.test.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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,6 +6,7 @@ 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();
|
||||||
@@ -13,7 +14,10 @@ vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
...actual,
|
...actual,
|
||||||
paths: () => ({
|
paths: () => ({
|
||||||
APPLICATIONS_PATH: "./__test__/drop/zips/output",
|
// @ts-ignore
|
||||||
|
...actual.paths(),
|
||||||
|
BASE_PATH: OUTPUT_BASE,
|
||||||
|
APPLICATIONS_PATH: OUTPUT_BASE,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -25,11 +29,17 @@ if (typeof window === "undefined") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseApp: ApplicationNested = {
|
const baseApp: ApplicationNested = {
|
||||||
railpackVersion: "0.2.2",
|
railpackVersion: "0.15.4",
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
|
createEnvFile: true,
|
||||||
|
bitbucketRepositorySlug: "",
|
||||||
herokuVersion: "",
|
herokuVersion: "",
|
||||||
giteaBranch: "",
|
giteaBranch: "",
|
||||||
|
buildServerId: "",
|
||||||
|
buildRegistryId: "",
|
||||||
|
buildRegistry: null,
|
||||||
|
args: [],
|
||||||
giteaBuildPath: "",
|
giteaBuildPath: "",
|
||||||
previewRequireCollaboratorPermissions: false,
|
previewRequireCollaboratorPermissions: false,
|
||||||
giteaId: "",
|
giteaId: "",
|
||||||
@@ -37,17 +47,22 @@ const baseApp: ApplicationNested = {
|
|||||||
giteaRepository: "",
|
giteaRepository: "",
|
||||||
cleanCache: false,
|
cleanCache: false,
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
|
rollbackRegistryId: "",
|
||||||
|
rollbackRegistry: null,
|
||||||
|
deployments: [],
|
||||||
enableSubmodules: false,
|
enableSubmodules: false,
|
||||||
applicationStatus: "done",
|
applicationStatus: "done",
|
||||||
triggerType: "push",
|
triggerType: "push",
|
||||||
appName: "",
|
appName: "",
|
||||||
autoDeploy: true,
|
autoDeploy: true,
|
||||||
|
endpointSpecSwarm: null,
|
||||||
serverId: "",
|
serverId: "",
|
||||||
registryUrl: "",
|
registryUrl: "",
|
||||||
branch: null,
|
branch: null,
|
||||||
dockerBuildStage: "",
|
dockerBuildStage: "",
|
||||||
isPreviewDeploymentsActive: false,
|
isPreviewDeploymentsActive: false,
|
||||||
previewBuildArgs: null,
|
previewBuildArgs: null,
|
||||||
|
previewBuildSecrets: null,
|
||||||
previewCertificateType: "none",
|
previewCertificateType: "none",
|
||||||
previewCustomCertResolver: null,
|
previewCustomCertResolver: null,
|
||||||
previewEnv: null,
|
previewEnv: null,
|
||||||
@@ -58,6 +73,7 @@ const baseApp: ApplicationNested = {
|
|||||||
previewWildcard: "",
|
previewWildcard: "",
|
||||||
environment: {
|
environment: {
|
||||||
env: "",
|
env: "",
|
||||||
|
isDefault: false,
|
||||||
environmentId: "",
|
environmentId: "",
|
||||||
name: "",
|
name: "",
|
||||||
createdAt: "",
|
createdAt: "",
|
||||||
@@ -73,6 +89,7 @@ const baseApp: ApplicationNested = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
buildArgs: null,
|
buildArgs: null,
|
||||||
|
buildSecrets: null,
|
||||||
buildPath: "/",
|
buildPath: "/",
|
||||||
gitlabPathNamespace: "",
|
gitlabPathNamespace: "",
|
||||||
buildType: "nixpacks",
|
buildType: "nixpacks",
|
||||||
@@ -134,8 +151,179 @@ const baseApp: ApplicationNested = {
|
|||||||
dockerContextPath: null,
|
dockerContextPath: null,
|
||||||
rollbackActive: false,
|
rollbackActive: false,
|
||||||
stopGracePeriodSwarm: null,
|
stopGracePeriodSwarm: 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 () => {
|
||||||
@@ -152,14 +340,12 @@ 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
apps/dokploy/__test__/drop/zips/payload/link
Symbolic link
1
apps/dokploy/__test__/drop/zips/payload/link
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/etc/passwd
|
||||||
BIN
apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip
Normal file
BIN
apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip
Normal file
Binary file not shown.
294
apps/dokploy/__test__/env/environment-access-fallback.test.ts
vendored
Normal file
294
apps/dokploy/__test__/env/environment-access-fallback.test.ts
vendored
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// Type definitions matching the project structure
|
||||||
|
type Environment = {
|
||||||
|
environmentId: string;
|
||||||
|
name: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Project = {
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
environments: Environment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function that selects the appropriate environment for a user
|
||||||
|
* This matches the logic used in search-command.tsx and show.tsx
|
||||||
|
*/
|
||||||
|
function selectAccessibleEnvironment(
|
||||||
|
project: Project | null | undefined,
|
||||||
|
): Environment | null {
|
||||||
|
if (!project || !project.environments || project.environments.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find default environment from accessible environments, or fall back to first accessible environment
|
||||||
|
const defaultEnvironment =
|
||||||
|
project.environments.find((environment) => environment.isDefault) ||
|
||||||
|
project.environments[0];
|
||||||
|
|
||||||
|
return defaultEnvironment || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Environment Access Fallback", () => {
|
||||||
|
describe("selectAccessibleEnvironment", () => {
|
||||||
|
it("should return default environment when user has access to it", () => {
|
||||||
|
const project: Project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "Test Project",
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
environmentId: "env-prod",
|
||||||
|
name: "production",
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environmentId: "env-dev",
|
||||||
|
name: "development",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.environmentId).toBe("env-prod");
|
||||||
|
expect(result?.isDefault).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return first accessible environment when user doesn't have access to default", () => {
|
||||||
|
// Simulating filtered environments (user only has access to development)
|
||||||
|
const project: Project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "Test Project",
|
||||||
|
environments: [
|
||||||
|
// Note: production is not in the list because user doesn't have access
|
||||||
|
{
|
||||||
|
environmentId: "env-dev",
|
||||||
|
name: "development",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environmentId: "env-staging",
|
||||||
|
name: "staging",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.environmentId).toBe("env-dev");
|
||||||
|
expect(result?.name).toBe("development");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return first environment when no default is marked but environments exist", () => {
|
||||||
|
const project: Project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "Test Project",
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
environmentId: "env-dev",
|
||||||
|
name: "development",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environmentId: "env-staging",
|
||||||
|
name: "staging",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.environmentId).toBe("env-dev");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when project has no accessible environments", () => {
|
||||||
|
const project: Project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "Test Project",
|
||||||
|
environments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when project is null", () => {
|
||||||
|
const result = selectAccessibleEnvironment(null);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when project is undefined", () => {
|
||||||
|
const result = selectAccessibleEnvironment(undefined);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle project with single accessible environment", () => {
|
||||||
|
const project: Project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "Test Project",
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
environmentId: "env-dev",
|
||||||
|
name: "development",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.environmentId).toBe("env-dev");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prioritize default environment even when it's not first in the array", () => {
|
||||||
|
const project: Project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "Test Project",
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
environmentId: "env-dev",
|
||||||
|
name: "development",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environmentId: "env-staging",
|
||||||
|
name: "staging",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environmentId: "env-prod",
|
||||||
|
name: "production",
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.environmentId).toBe("env-prod");
|
||||||
|
expect(result?.isDefault).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multiple default environments by returning the first one found", () => {
|
||||||
|
// Edge case: multiple environments marked as default (shouldn't happen, but test it)
|
||||||
|
const project: Project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "Test Project",
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
environmentId: "env-prod-1",
|
||||||
|
name: "production-1",
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environmentId: "env-prod-2",
|
||||||
|
name: "production-2",
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.isDefault).toBe(true);
|
||||||
|
// Should return the first default found
|
||||||
|
expect(result?.environmentId).toBe("env-prod-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should work correctly when user has access to multiple environments including default", () => {
|
||||||
|
const project: Project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "Test Project",
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
environmentId: "env-prod",
|
||||||
|
name: "production",
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environmentId: "env-dev",
|
||||||
|
name: "development",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environmentId: "env-staging",
|
||||||
|
name: "staging",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.environmentId).toBe("env-prod");
|
||||||
|
expect(result?.isDefault).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle real-world scenario: user with only development access", () => {
|
||||||
|
// This simulates the exact bug we're fixing:
|
||||||
|
// User has access to development but not production (default)
|
||||||
|
// The filtered environments array only contains development
|
||||||
|
const project: Project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "My Project",
|
||||||
|
environments: [
|
||||||
|
// Only development is accessible (production was filtered out)
|
||||||
|
{
|
||||||
|
environmentId: "env-dev-123",
|
||||||
|
name: "development",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.environmentId).toBe("env-dev-123");
|
||||||
|
expect(result?.name).toBe("development");
|
||||||
|
// Should not be null even though it's not the default
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Environment selection edge cases", () => {
|
||||||
|
it("should handle project with environments property as undefined", () => {
|
||||||
|
const project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "Test Project",
|
||||||
|
environments: undefined,
|
||||||
|
} as unknown as Project;
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle project with null environments array", () => {
|
||||||
|
const project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "Test Project",
|
||||||
|
environments: null,
|
||||||
|
} as unknown as Project;
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
311
apps/dokploy/__test__/env/environment.test.ts
vendored
311
apps/dokploy/__test__/env/environment.test.ts
vendored
@@ -1,4 +1,7 @@
|
|||||||
import { prepareEnvironmentVariables } from "@dokploy/server/index";
|
import {
|
||||||
|
prepareEnvironmentVariables,
|
||||||
|
prepareEnvironmentVariablesForShell,
|
||||||
|
} from "@dokploy/server/index";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const projectEnv = `
|
const projectEnv = `
|
||||||
@@ -332,4 +335,310 @@ IS_DEV=\${{environment.DEVELOPMENT}}
|
|||||||
"IS_DEV=0",
|
"IS_DEV=0",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("handles environment variables with single quotes in values", () => {
|
||||||
|
const envWithSingleQuotes = `
|
||||||
|
ENV_VARIABLE='ENVITONME'NT'
|
||||||
|
ANOTHER_VAR='value with 'quotes' inside'
|
||||||
|
SIMPLE_VAR=no-quotes
|
||||||
|
`;
|
||||||
|
|
||||||
|
const serviceWithSingleQuotes = `
|
||||||
|
TEST_VAR=\${{environment.ENV_VARIABLE}}
|
||||||
|
ANOTHER_TEST=\${{environment.ANOTHER_VAR}}
|
||||||
|
SIMPLE=\${{environment.SIMPLE_VAR}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariables(
|
||||||
|
serviceWithSingleQuotes,
|
||||||
|
"",
|
||||||
|
envWithSingleQuotes,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved).toEqual([
|
||||||
|
"TEST_VAR=ENVITONME'NT",
|
||||||
|
"ANOTHER_TEST=value with 'quotes' inside",
|
||||||
|
"SIMPLE=no-quotes",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("prepareEnvironmentVariablesForShell (shell escaping)", () => {
|
||||||
|
it("escapes single quotes in environment variable values", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
ENV_VARIABLE='ENVITONME'NT'
|
||||||
|
ANOTHER_VAR='value with 'quotes' inside'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
// shell-quote should wrap these in double quotes
|
||||||
|
expect(resolved).toEqual([
|
||||||
|
`"ENV_VARIABLE=ENVITONME'NT"`,
|
||||||
|
`"ANOTHER_VAR=value with 'quotes' inside"`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes double quotes in environment variable values", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
MESSAGE="Hello "World""
|
||||||
|
QUOTED_PATH="/path/to/"file""
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
// shell-quote wraps in single quotes when there are double quotes inside
|
||||||
|
expect(resolved).toEqual([
|
||||||
|
`'MESSAGE=Hello "World"'`,
|
||||||
|
`'QUOTED_PATH=/path/to/"file"'`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes dollar signs in environment variable values", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
PRICE=$100
|
||||||
|
VARIABLE=$HOME/path
|
||||||
|
TEMPLATE=Hello $USER
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
// Dollar signs should be escaped to prevent variable expansion
|
||||||
|
for (const env of resolved) {
|
||||||
|
expect(env).toContain("$");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes backticks in environment variable values", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
COMMAND=\`echo "test"\`
|
||||||
|
NESTED=value with \`backticks\` inside
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
// Backticks are escaped/removed by dotenv parsing, but values should be safely quoted
|
||||||
|
expect(resolved.length).toBe(2);
|
||||||
|
expect(resolved[0]).toContain("COMMAND");
|
||||||
|
expect(resolved[1]).toContain("NESTED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles environment variables with spaces", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
FULL_NAME="John Doe"
|
||||||
|
MESSAGE='Hello World'
|
||||||
|
SENTENCE=This is a test
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
// shell-quote uses single quotes for strings with spaces
|
||||||
|
expect(resolved).toEqual([
|
||||||
|
`'FULL_NAME=John Doe'`,
|
||||||
|
`'MESSAGE=Hello World'`,
|
||||||
|
`'SENTENCE=This is a test'`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles environment variables with backslashes", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
WINDOWS_PATH=C:\\Users\\Documents
|
||||||
|
ESCAPED=value\\with\\backslashes
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
// Backslashes should be properly escaped
|
||||||
|
expect(resolved.length).toBe(2);
|
||||||
|
for (const env of resolved) {
|
||||||
|
expect(env).toContain("\\");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles simple environment variables without special characters", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
DEBUG=true
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
// shell-quote escapes the = sign in some cases
|
||||||
|
expect(resolved).toEqual([
|
||||||
|
"NODE_ENV\\=production",
|
||||||
|
"PORT\\=3000",
|
||||||
|
"DEBUG\\=true",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles environment variables with mixed special characters", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
COMPLEX='value with "double" and 'single' quotes'
|
||||||
|
BASH_COMMAND=echo "$HOME" && echo 'test'
|
||||||
|
WEIRD=\`echo "$VAR"\` with 'quotes' and "more"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
// All should be escaped, none should throw errors
|
||||||
|
expect(resolved.length).toBe(3);
|
||||||
|
// Verify each can be safely used in shell
|
||||||
|
for (const env of resolved) {
|
||||||
|
expect(typeof env).toBe("string");
|
||||||
|
expect(env.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles environment variables with newlines", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
MULTILINE="line1
|
||||||
|
line2
|
||||||
|
line3"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
expect(resolved.length).toBe(1);
|
||||||
|
expect(resolved[0]).toContain("MULTILINE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty environment variable values", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
EMPTY=
|
||||||
|
EMPTY_QUOTED=""
|
||||||
|
EMPTY_SINGLE=''
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
// shell-quote escapes the = sign for empty values
|
||||||
|
expect(resolved).toEqual([
|
||||||
|
"EMPTY\\=",
|
||||||
|
"EMPTY_QUOTED\\=",
|
||||||
|
"EMPTY_SINGLE\\=",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles environment variables with equals signs in values", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
EQUATION=a=b+c
|
||||||
|
CONNECTION_STRING=user=admin;password=test
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
expect(resolved.length).toBe(2);
|
||||||
|
expect(resolved[0]).toContain("EQUATION");
|
||||||
|
expect(resolved[1]).toContain("CONNECTION_STRING");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves and escapes environment variables together", () => {
|
||||||
|
const projectEnv = `
|
||||||
|
BASE_URL=https://example.com
|
||||||
|
API_KEY='secret-key-with-quotes'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const environmentEnv = `
|
||||||
|
ENV_NAME=production
|
||||||
|
DB_PASS='pa$$word'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const serviceEnv = `
|
||||||
|
FULL_URL=\${{project.BASE_URL}}/api
|
||||||
|
AUTH_KEY=\${{project.API_KEY}}
|
||||||
|
ENVIRONMENT=\${{environment.ENV_NAME}}
|
||||||
|
DB_PASSWORD=\${{environment.DB_PASS}}
|
||||||
|
CUSTOM='value with 'quotes' inside'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(
|
||||||
|
serviceEnv,
|
||||||
|
projectEnv,
|
||||||
|
environmentEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.length).toBe(5);
|
||||||
|
// All resolved values should be properly escaped
|
||||||
|
for (const env of resolved) {
|
||||||
|
expect(typeof env).toBe("string");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles environment variables with semicolons and ampersands", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
COMMAND=echo "test" && echo "test2"
|
||||||
|
MULTIPLE=cmd1; cmd2; cmd3
|
||||||
|
URL_WITH_PARAMS=https://example.com?a=1&b=2&c=3
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
expect(resolved.length).toBe(3);
|
||||||
|
// These should be safely escaped to prevent command injection
|
||||||
|
for (const env of resolved) {
|
||||||
|
expect(typeof env).toBe("string");
|
||||||
|
expect(env.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles environment variables with pipes and redirects", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
PIPE_COMMAND=cat file | grep test
|
||||||
|
REDIRECT=echo "test" > output.txt
|
||||||
|
BOTH=cat input.txt | grep pattern > output.txt
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
expect(resolved.length).toBe(3);
|
||||||
|
// Pipes and redirects should be safely quoted
|
||||||
|
expect(resolved[0]).toContain("PIPE_COMMAND");
|
||||||
|
expect(resolved[1]).toContain("REDIRECT");
|
||||||
|
expect(resolved[2]).toContain("BOTH");
|
||||||
|
// At least one should contain a pipe
|
||||||
|
const hasPipe = resolved.some((env) => env.includes("|"));
|
||||||
|
expect(hasPipe).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles environment variables with parentheses and brackets", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
MATH=(a+b)*c
|
||||||
|
ARRAY=[1,2,3]
|
||||||
|
JSON={"key":"value"}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
expect(resolved.length).toBe(3);
|
||||||
|
expect(resolved[0]).toContain("(");
|
||||||
|
expect(resolved[1]).toContain("[");
|
||||||
|
expect(resolved[2]).toContain("{");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles very long environment variable values", () => {
|
||||||
|
const longValue = "a".repeat(10000);
|
||||||
|
const serviceEnv = `LONG_VAR=${longValue}`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
expect(resolved.length).toBe(1);
|
||||||
|
expect(resolved[0]).toContain("LONG_VAR");
|
||||||
|
expect(resolved[0]?.length).toBeGreaterThan(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles special unicode characters in environment variables", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
EMOJI=Hello 🌍 World 🚀
|
||||||
|
CHINESE=你好世界
|
||||||
|
SPECIAL=café résumé naïve
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
expect(resolved.length).toBe(3);
|
||||||
|
expect(resolved[0]).toContain("🌍");
|
||||||
|
expect(resolved[1]).toContain("你好");
|
||||||
|
expect(resolved[2]).toContain("café");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
184
apps/dokploy/__test__/env/stack-environment.test.ts
vendored
Normal file
184
apps/dokploy/__test__/env/stack-environment.test.ts
vendored
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { getEnviromentVariablesObject } from "@dokploy/server/index";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const projectEnv = `
|
||||||
|
ENVIRONMENT=staging
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
|
||||||
|
PORT=3000
|
||||||
|
`;
|
||||||
|
|
||||||
|
const environmentEnv = `
|
||||||
|
NODE_ENV=development
|
||||||
|
API_URL=https://api.dev.example.com
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
DATABASE_NAME=dev_database
|
||||||
|
SECRET_KEY=env-secret-123
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => {
|
||||||
|
it("resolves environment variables correctly for Stack compose", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
FOO=\${{environment.NODE_ENV}}
|
||||||
|
BAR=\${{environment.API_URL}}
|
||||||
|
BAZ=test
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(
|
||||||
|
serviceEnv,
|
||||||
|
projectEnv,
|
||||||
|
environmentEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
FOO: "development",
|
||||||
|
BAR: "https://api.dev.example.com",
|
||||||
|
BAZ: "test",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves both project and environment variables for Stack compose", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
ENVIRONMENT=\${{project.ENVIRONMENT}}
|
||||||
|
NODE_ENV=\${{environment.NODE_ENV}}
|
||||||
|
API_URL=\${{environment.API_URL}}
|
||||||
|
DATABASE_URL=\${{project.DATABASE_URL}}
|
||||||
|
SERVICE_PORT=4000
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(
|
||||||
|
serviceEnv,
|
||||||
|
projectEnv,
|
||||||
|
environmentEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ENVIRONMENT: "staging",
|
||||||
|
NODE_ENV: "development",
|
||||||
|
API_URL: "https://api.dev.example.com",
|
||||||
|
DATABASE_URL: "postgres://postgres:postgres@localhost:5432/project_db",
|
||||||
|
SERVICE_PORT: "4000",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple environment references in single value for Stack compose", () => {
|
||||||
|
const multiRefEnv = `
|
||||||
|
HOST=localhost
|
||||||
|
PORT=5432
|
||||||
|
USERNAME=postgres
|
||||||
|
PASSWORD=secret123
|
||||||
|
`;
|
||||||
|
|
||||||
|
const serviceEnv = `
|
||||||
|
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error for undefined environment variables in Stack compose", () => {
|
||||||
|
const serviceWithUndefined = `
|
||||||
|
UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv),
|
||||||
|
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows service variables to override environment variables in Stack compose", () => {
|
||||||
|
const serviceOverrideEnv = `
|
||||||
|
NODE_ENV=production
|
||||||
|
API_URL=\${{environment.API_URL}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(
|
||||||
|
serviceOverrideEnv,
|
||||||
|
"",
|
||||||
|
environmentEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
NODE_ENV: "production",
|
||||||
|
API_URL: "https://api.dev.example.com",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves complex references with project, environment, and service variables for Stack compose", () => {
|
||||||
|
const complexServiceEnv = `
|
||||||
|
FULL_DATABASE_URL=\${{project.DATABASE_URL}}/\${{environment.DATABASE_NAME}}
|
||||||
|
API_ENDPOINT=\${{environment.API_URL}}/\${{project.ENVIRONMENT}}/api
|
||||||
|
SERVICE_NAME=my-service
|
||||||
|
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(
|
||||||
|
complexServiceEnv,
|
||||||
|
projectEnv,
|
||||||
|
environmentEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
FULL_DATABASE_URL:
|
||||||
|
"postgres://postgres:postgres@localhost:5432/project_db/dev_database",
|
||||||
|
API_ENDPOINT: "https://api.dev.example.com/staging/api",
|
||||||
|
SERVICE_NAME: "my-service",
|
||||||
|
COMPLEX_VAR: "my-service-development-staging",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maintains precedence: service > environment > project in Stack compose", () => {
|
||||||
|
const conflictingProjectEnv = `
|
||||||
|
NODE_ENV=production-project
|
||||||
|
API_URL=https://project.api.com
|
||||||
|
DATABASE_NAME=project_db
|
||||||
|
`;
|
||||||
|
|
||||||
|
const conflictingEnvironmentEnv = `
|
||||||
|
NODE_ENV=development-environment
|
||||||
|
API_URL=https://environment.api.com
|
||||||
|
DATABASE_NAME=env_db
|
||||||
|
`;
|
||||||
|
|
||||||
|
const serviceWithConflicts = `
|
||||||
|
NODE_ENV=service-override
|
||||||
|
PROJECT_ENV=\${{project.NODE_ENV}}
|
||||||
|
ENV_VAR=\${{environment.API_URL}}
|
||||||
|
DB_NAME=\${{environment.DATABASE_NAME}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(
|
||||||
|
serviceWithConflicts,
|
||||||
|
conflictingProjectEnv,
|
||||||
|
conflictingEnvironmentEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
NODE_ENV: "service-override",
|
||||||
|
PROJECT_ENV: "production-project",
|
||||||
|
ENV_VAR: "https://environment.api.com",
|
||||||
|
DB_NAME: "env_db",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty environment variables in Stack compose", () => {
|
||||||
|
const serviceWithEmpty = `
|
||||||
|
SERVICE_VAR=test
|
||||||
|
PROJECT_VAR=\${{project.ENVIRONMENT}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(
|
||||||
|
serviceWithEmpty,
|
||||||
|
projectEnv,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
SERVICE_VAR: "test",
|
||||||
|
PROJECT_VAR: "staging",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
144
apps/dokploy/__test__/permissions/check-permission.test.ts
Normal file
144
apps/dokploy/__test__/permissions/check-permission.test.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
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("static roles 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("member bypasses schedule.delete", async () => {
|
||||||
|
memberToReturn = mockMemberData("member");
|
||||||
|
await expect(
|
||||||
|
checkPermission(ctx, { schedule: ["delete"] }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("member bypasses multiple enterprise permissions at once", async () => {
|
||||||
|
memberToReturn = mockMemberData("member");
|
||||||
|
await expect(
|
||||||
|
checkPermission(ctx, {
|
||||||
|
deployment: ["read"],
|
||||||
|
backup: ["create"],
|
||||||
|
domain: ["delete"],
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
enterpriseOnlyResources,
|
||||||
|
statements,
|
||||||
|
} from "@dokploy/server/lib/access-control";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
161
apps/dokploy/__test__/permissions/resolve-permissions.test.ts
Normal file
161
apps/dokploy/__test__/permissions/resolve-permissions.test.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
132
apps/dokploy/__test__/permissions/service-access.test.ts
Normal file
132
apps/dokploy/__test__/permissions/service-access.test.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,4 +54,22 @@ describe("processLogs", () => {
|
|||||||
const result = parseRawConfig(entryWithWhitespace);
|
const result = parseRawConfig(entryWithWhitespace);
|
||||||
expect(result.data).toHaveLength(2);
|
expect(result.data).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should filter out Dokploy dashboard requests", () => {
|
||||||
|
const dokployDashboardEntry = `{"ClientAddr":"172.71.187.131:9485","ClientHost":"172.71.187.131","ClientPort":"9485","ClientUsername":"-","DownstreamContentSize":14550,"DownstreamStatus":200,"Duration":57681682,"OriginContentSize":14550,"OriginDuration":57612242,"OriginStatus":200,"Overhead":69440,"RequestAddr":"hostinger.dokploy.com","RequestContentSize":0,"RequestCount":20142,"RequestHost":"hostinger.dokploy.com","RequestMethod":"GET","RequestPath":"/_next/data/cb_zzI4Rp9G7Q7djrFKh0/en/dashboard/traefik.json","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"dokploy-router-app-secure@file","ServiceAddr":"dokploy:3000","ServiceName":"dokploy-service-app@file","ServiceURL":"http://dokploy:3000","StartLocal":"2025-12-10T05:10:41.957755949Z","StartUTC":"2025-12-10T05:10:41.957755949Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-12-10T05:10:42Z"}`;
|
||||||
|
|
||||||
|
// Test with only Dokploy dashboard entry - should be filtered out
|
||||||
|
const resultOnlyDokploy = parseRawConfig(dokployDashboardEntry);
|
||||||
|
expect(resultOnlyDokploy.data).toHaveLength(0);
|
||||||
|
expect(resultOnlyDokploy.totalCount).toBe(0);
|
||||||
|
|
||||||
|
// Test with mixed entries - Dokploy should be filtered, others should remain
|
||||||
|
const mixedEntries = `${dokployDashboardEntry}\n${sampleLogEntry}`;
|
||||||
|
const resultMixed = parseRawConfig(mixedEntries);
|
||||||
|
expect(resultMixed.data).toHaveLength(1);
|
||||||
|
expect(resultMixed.totalCount).toBe(1);
|
||||||
|
expect(resultMixed.data[0]?.ServiceName).not.toBe(
|
||||||
|
"dokploy-service-app@file",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
||||||
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
|
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
type MockCreateServiceOptions = {
|
type MockCreateServiceOptions = {
|
||||||
StopGracePeriod?: number;
|
TaskTemplate?: {
|
||||||
|
ContainerSpec?: {
|
||||||
|
StopGracePeriod?: number;
|
||||||
|
Ulimits?: Array<{ Name: string; Soft: number; Hard: number }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } =
|
const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } =
|
||||||
vi.hoisted(() => {
|
vi.hoisted(() => {
|
||||||
const inspect = vi.fn<[], Promise<never>>();
|
const inspect = vi.fn<() => Promise<never>>();
|
||||||
const getService = vi.fn(() => ({ inspect }));
|
const getService = vi.fn(() => ({ inspect }));
|
||||||
const createService = vi.fn<[MockCreateServiceOptions], Promise<void>>(
|
const createService = vi.fn<
|
||||||
async () => undefined,
|
(opts: MockCreateServiceOptions) => Promise<void>
|
||||||
);
|
>(async () => undefined);
|
||||||
const getRemoteDocker = vi.fn(async () => ({
|
const getRemoteDocker = vi.fn(async () => ({
|
||||||
getService,
|
getService,
|
||||||
createService,
|
createService,
|
||||||
@@ -54,6 +58,7 @@ const createApplication = (
|
|||||||
},
|
},
|
||||||
replicas: 1,
|
replicas: 1,
|
||||||
stopGracePeriodSwarm: 0n,
|
stopGracePeriodSwarm: 0n,
|
||||||
|
ulimitsSwarm: null,
|
||||||
serverId: "server-id",
|
serverId: "server-id",
|
||||||
...overrides,
|
...overrides,
|
||||||
}) as unknown as ApplicationNested;
|
}) as unknown as ApplicationNested;
|
||||||
@@ -77,13 +82,17 @@ describe("mechanizeDockerContainer", () => {
|
|||||||
await mechanizeDockerContainer(application);
|
await mechanizeDockerContainer(application);
|
||||||
|
|
||||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||||
const call = createServiceMock.mock.calls[0];
|
const call = createServiceMock.mock.calls[0] as
|
||||||
|
| [MockCreateServiceOptions]
|
||||||
|
| undefined;
|
||||||
if (!call) {
|
if (!call) {
|
||||||
throw new Error("createServiceMock should have been called once");
|
throw new Error("createServiceMock should have been called once");
|
||||||
}
|
}
|
||||||
const [settings] = call;
|
const [settings] = call;
|
||||||
expect(settings.StopGracePeriod).toBe(0);
|
expect(settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(0);
|
||||||
expect(typeof settings.StopGracePeriod).toBe("number");
|
expect(typeof settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(
|
||||||
|
"number",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => {
|
it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => {
|
||||||
@@ -91,12 +100,62 @@ describe("mechanizeDockerContainer", () => {
|
|||||||
|
|
||||||
await mechanizeDockerContainer(application);
|
await mechanizeDockerContainer(application);
|
||||||
|
|
||||||
|
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||||
|
const call = createServiceMock.mock.calls[0] as
|
||||||
|
| [MockCreateServiceOptions]
|
||||||
|
| undefined;
|
||||||
|
if (!call) {
|
||||||
|
throw new Error("createServiceMock should have been called once");
|
||||||
|
}
|
||||||
|
const [settings] = call;
|
||||||
|
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty(
|
||||||
|
"StopGracePeriod",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes ulimits to ContainerSpec when ulimitsSwarm is defined", async () => {
|
||||||
|
const ulimits = [
|
||||||
|
{ Name: "nofile", Soft: 10000, Hard: 20000 },
|
||||||
|
{ Name: "nproc", Soft: 4096, Hard: 8192 },
|
||||||
|
];
|
||||||
|
const application = createApplication({ ulimitsSwarm: ulimits });
|
||||||
|
|
||||||
|
await mechanizeDockerContainer(application);
|
||||||
|
|
||||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||||
const call = createServiceMock.mock.calls[0];
|
const call = createServiceMock.mock.calls[0];
|
||||||
if (!call) {
|
if (!call) {
|
||||||
throw new Error("createServiceMock should have been called once");
|
throw new Error("createServiceMock should have been called once");
|
||||||
}
|
}
|
||||||
const [settings] = call;
|
const [settings] = call;
|
||||||
expect(settings).not.toHaveProperty("StopGracePeriod");
|
expect(settings.TaskTemplate?.ContainerSpec?.Ulimits).toEqual(ulimits);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits Ulimits when ulimitsSwarm is null", async () => {
|
||||||
|
const application = createApplication({ ulimitsSwarm: null });
|
||||||
|
|
||||||
|
await mechanizeDockerContainer(application);
|
||||||
|
|
||||||
|
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||||
|
const call = createServiceMock.mock.calls[0];
|
||||||
|
if (!call) {
|
||||||
|
throw new Error("createServiceMock should have been called once");
|
||||||
|
}
|
||||||
|
const [settings] = call;
|
||||||
|
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits Ulimits when ulimitsSwarm is an empty array", async () => {
|
||||||
|
const application = createApplication({ ulimitsSwarm: [] });
|
||||||
|
|
||||||
|
await mechanizeDockerContainer(application);
|
||||||
|
|
||||||
|
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||||
|
const call = createServiceMock.mock.calls[0];
|
||||||
|
if (!call) {
|
||||||
|
throw new Error("createServiceMock should have been called once");
|
||||||
|
}
|
||||||
|
const [settings] = call;
|
||||||
|
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
43
apps/dokploy/__test__/setup.ts
Normal file
43
apps/dokploy/__test__/setup.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock the DB module so tests that import from @dokploy/server (barrel)
|
||||||
|
* never open a real TCP connection to PostgreSQL (e.g. in CI where no DB runs).
|
||||||
|
* Without this, loading the server barrel pulls in lib/auth and db, which
|
||||||
|
* connect to localhost:5432 and cause ECONNREFUSED.
|
||||||
|
*/
|
||||||
|
vi.mock("@dokploy/server/db", () => {
|
||||||
|
const chain = () => chain;
|
||||||
|
chain.set = () => chain;
|
||||||
|
chain.where = () => chain;
|
||||||
|
chain.values = () => chain;
|
||||||
|
chain.returning = () => Promise.resolve([{}]);
|
||||||
|
chain.from = () => chain;
|
||||||
|
chain.innerJoin = () => chain;
|
||||||
|
chain.then = (resolve: (value: unknown) => void) => {
|
||||||
|
resolve([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableMock = {
|
||||||
|
findFirst: vi.fn(() => Promise.resolve(undefined)),
|
||||||
|
findMany: vi.fn(() => Promise.resolve([])),
|
||||||
|
insert: vi.fn(() => Promise.resolve([{}])),
|
||||||
|
update: vi.fn(() => chain),
|
||||||
|
delete: vi.fn(() => chain),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
db: {
|
||||||
|
select: vi.fn(() => chain),
|
||||||
|
insert: vi.fn(() => ({
|
||||||
|
values: () => ({ returning: () => Promise.resolve([{}]) }),
|
||||||
|
})),
|
||||||
|
update: vi.fn(() => chain),
|
||||||
|
delete: vi.fn(() => chain),
|
||||||
|
query: new Proxy({} as Record<string, typeof tableMock>, {
|
||||||
|
get: () => tableMock,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
dbUrl: "postgres://mock:mock@localhost:5432/mock",
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -161,6 +161,50 @@ describe("helpers functions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Empty string variables", () => {
|
||||||
|
it("should replace variables with empty string values correctly", () => {
|
||||||
|
const variables = {
|
||||||
|
smtp_username: "",
|
||||||
|
smtp_password: "",
|
||||||
|
non_empty: "value",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result1 = processValue("${smtp_username}", variables, mockSchema);
|
||||||
|
expect(result1).toBe("");
|
||||||
|
|
||||||
|
const result2 = processValue("${smtp_password}", variables, mockSchema);
|
||||||
|
expect(result2).toBe("");
|
||||||
|
|
||||||
|
const result3 = processValue("${non_empty}", variables, mockSchema);
|
||||||
|
expect(result3).toBe("value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not replace undefined variables", () => {
|
||||||
|
const variables = {
|
||||||
|
defined_var: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = processValue("${undefined_var}", variables, mockSchema);
|
||||||
|
expect(result).toBe("${undefined_var}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle mixed empty and non-empty variables in template", () => {
|
||||||
|
const variables = {
|
||||||
|
smtp_address: "smtp.example.com",
|
||||||
|
smtp_port: "2525",
|
||||||
|
smtp_username: "",
|
||||||
|
smtp_password: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const template =
|
||||||
|
"SMTP_ADDRESS=${smtp_address} SMTP_PORT=${smtp_port} SMTP_USERNAME=${smtp_username} SMTP_PASSWORD=${smtp_password}";
|
||||||
|
const result = processValue(template, variables, mockSchema);
|
||||||
|
expect(result).toBe(
|
||||||
|
"SMTP_ADDRESS=smtp.example.com SMTP_PORT=2525 SMTP_USERNAME= SMTP_PASSWORD=",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("${jwt}", () => {
|
describe("${jwt}", () => {
|
||||||
it("should generate a JWT string", () => {
|
it("should generate a JWT string", () => {
|
||||||
const jwt = processValue("${jwt}", {}, mockSchema);
|
const jwt = processValue("${jwt}", {}, mockSchema);
|
||||||
@@ -228,5 +272,58 @@ describe("helpers functions", () => {
|
|||||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI",
|
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should handle JWT payload with newlines and whitespace by trimming them", () => {
|
||||||
|
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
|
||||||
|
const expiry = iat + 3600;
|
||||||
|
const payloadWithNewlines = `{
|
||||||
|
"role": "anon",
|
||||||
|
"iss": "supabase",
|
||||||
|
"exp": ${expiry}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const jwt = processValue(
|
||||||
|
"${jwt:secret:payload}",
|
||||||
|
{
|
||||||
|
secret: "mysecret",
|
||||||
|
payload: payloadWithNewlines,
|
||||||
|
},
|
||||||
|
mockSchema,
|
||||||
|
);
|
||||||
|
expect(jwt).toMatch(jwtMatchExp);
|
||||||
|
const parts = jwt.split(".") as JWTParts;
|
||||||
|
jwtCheckHeader(parts[0]);
|
||||||
|
const decodedPayload = jwtBase64Decode(parts[1]);
|
||||||
|
expect(decodedPayload).toHaveProperty("role");
|
||||||
|
expect(decodedPayload.role).toEqual("anon");
|
||||||
|
expect(decodedPayload).toHaveProperty("iss");
|
||||||
|
expect(decodedPayload.iss).toEqual("supabase");
|
||||||
|
expect(decodedPayload).toHaveProperty("exp");
|
||||||
|
expect(decodedPayload.exp).toEqual(expiry);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle JWT payload with leading and trailing whitespace", () => {
|
||||||
|
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
|
||||||
|
const expiry = iat + 3600;
|
||||||
|
const payloadWithWhitespace = ` {"role": "service_role", "iss": "supabase", "exp": ${expiry}} `;
|
||||||
|
const jwt = processValue(
|
||||||
|
"${jwt:secret:payload}",
|
||||||
|
{
|
||||||
|
secret: "mysecret",
|
||||||
|
payload: payloadWithWhitespace,
|
||||||
|
},
|
||||||
|
mockSchema,
|
||||||
|
);
|
||||||
|
expect(jwt).toMatch(jwtMatchExp);
|
||||||
|
const parts = jwt.split(".") as JWTParts;
|
||||||
|
jwtCheckHeader(parts[0]);
|
||||||
|
const decodedPayload = jwtBase64Decode(parts[1]);
|
||||||
|
expect(decodedPayload).toHaveProperty("role");
|
||||||
|
expect(decodedPayload.role).toEqual("service_role");
|
||||||
|
expect(decodedPayload).toHaveProperty("iss");
|
||||||
|
expect(decodedPayload.iss).toEqual("supabase");
|
||||||
|
expect(decodedPayload).toHaveProperty("exp");
|
||||||
|
expect(decodedPayload.exp).toEqual(expiry);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,19 +5,27 @@ vi.mock("node:fs", () => ({
|
|||||||
default: fs,
|
default: fs,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import type { FileConfig, User } from "@dokploy/server";
|
import type { FileConfig } from "@dokploy/server";
|
||||||
import {
|
import {
|
||||||
createDefaultServerTraefikConfig,
|
createDefaultServerTraefikConfig,
|
||||||
loadOrCreateConfig,
|
loadOrCreateConfig,
|
||||||
updateServerTraefik,
|
updateServerTraefik,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
import type { webServerSettings } from "@dokploy/server/db/schema";
|
||||||
import { beforeEach, expect, test, vi } from "vitest";
|
import { beforeEach, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
const baseAdmin: User = {
|
type WebServerSettings = typeof webServerSettings.$inferSelect;
|
||||||
|
|
||||||
|
const baseSettings: WebServerSettings = {
|
||||||
|
id: "",
|
||||||
https: false,
|
https: false,
|
||||||
enablePaidFeatures: false,
|
certificateType: "none",
|
||||||
allowImpersonation: false,
|
host: null,
|
||||||
role: "user",
|
serverIp: null,
|
||||||
|
letsEncryptEmail: null,
|
||||||
|
sshPrivateKey: null,
|
||||||
|
enableDockerCleanup: false,
|
||||||
|
logCleanupCron: null,
|
||||||
metricsConfig: {
|
metricsConfig: {
|
||||||
containers: {
|
containers: {
|
||||||
refreshRate: 20,
|
refreshRate: 20,
|
||||||
@@ -40,33 +48,25 @@ const baseAdmin: User = {
|
|||||||
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,
|
||||||
createdAt: new Date(),
|
createdAt: null,
|
||||||
serverIp: null,
|
|
||||||
certificateType: "none",
|
|
||||||
host: null,
|
|
||||||
letsEncryptEmail: null,
|
|
||||||
sshPrivateKey: null,
|
|
||||||
enableDockerCleanup: false,
|
|
||||||
logCleanupCron: null,
|
|
||||||
serversQuantity: 0,
|
|
||||||
stripeCustomerId: "",
|
|
||||||
stripeSubscriptionId: "",
|
|
||||||
banExpires: new Date(),
|
|
||||||
banned: true,
|
|
||||||
banReason: "",
|
|
||||||
email: "",
|
|
||||||
expirationDate: "",
|
|
||||||
id: "",
|
|
||||||
isRegistered: false,
|
|
||||||
name: "",
|
|
||||||
createdAt2: new Date().toISOString(),
|
|
||||||
emailVerified: false,
|
|
||||||
image: "",
|
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
twoFactorEnabled: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -84,7 +84,7 @@ test("Should read the configuration file", () => {
|
|||||||
test("Should apply redirect-to-https", () => {
|
test("Should apply redirect-to-https", () => {
|
||||||
updateServerTraefik(
|
updateServerTraefik(
|
||||||
{
|
{
|
||||||
...baseAdmin,
|
...baseSettings,
|
||||||
https: true,
|
https: true,
|
||||||
certificateType: "letsencrypt",
|
certificateType: "letsencrypt",
|
||||||
},
|
},
|
||||||
@@ -99,7 +99,7 @@ test("Should apply redirect-to-https", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Should change only host when no certificate", () => {
|
test("Should change only host when no certificate", () => {
|
||||||
updateServerTraefik(baseAdmin, "example.com");
|
updateServerTraefik(baseSettings, "example.com");
|
||||||
|
|
||||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ test("Should change only host when no certificate", () => {
|
|||||||
test("Should not touch config without host", () => {
|
test("Should not touch config without host", () => {
|
||||||
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
|
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
|
||||||
|
|
||||||
updateServerTraefik(baseAdmin, null);
|
updateServerTraefik(baseSettings, null);
|
||||||
|
|
||||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||||
|
|
||||||
@@ -118,11 +118,14 @@ test("Should not touch config without host", () => {
|
|||||||
|
|
||||||
test("Should remove websecure if https rollback to http", () => {
|
test("Should remove websecure if https rollback to http", () => {
|
||||||
updateServerTraefik(
|
updateServerTraefik(
|
||||||
{ ...baseAdmin, certificateType: "letsencrypt" },
|
{ ...baseSettings, certificateType: "letsencrypt" },
|
||||||
"example.com",
|
"example.com",
|
||||||
);
|
);
|
||||||
|
|
||||||
updateServerTraefik({ ...baseAdmin, certificateType: "none" }, "example.com");
|
updateServerTraefik(
|
||||||
|
{ ...baseSettings, certificateType: "none" },
|
||||||
|
"example.com",
|
||||||
|
);
|
||||||
|
|
||||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,28 @@ import { createRouterConfig } from "@dokploy/server";
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
|
|
||||||
const baseApp: ApplicationNested = {
|
const baseApp: ApplicationNested = {
|
||||||
railpackVersion: "0.2.2",
|
railpackVersion: "0.15.4",
|
||||||
rollbackActive: false,
|
rollbackActive: false,
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
|
createEnvFile: true,
|
||||||
|
bitbucketRepositorySlug: "",
|
||||||
herokuVersion: "",
|
herokuVersion: "",
|
||||||
giteaRepository: "",
|
giteaRepository: "",
|
||||||
giteaOwner: "",
|
giteaOwner: "",
|
||||||
giteaBranch: "",
|
giteaBranch: "",
|
||||||
|
buildServerId: "",
|
||||||
|
buildRegistryId: "",
|
||||||
|
buildRegistry: null,
|
||||||
giteaBuildPath: "",
|
giteaBuildPath: "",
|
||||||
giteaId: "",
|
giteaId: "",
|
||||||
|
args: [],
|
||||||
|
rollbackRegistryId: "",
|
||||||
|
rollbackRegistry: null,
|
||||||
|
deployments: [],
|
||||||
cleanCache: false,
|
cleanCache: false,
|
||||||
applicationStatus: "done",
|
applicationStatus: "done",
|
||||||
|
endpointSpecSwarm: null,
|
||||||
appName: "",
|
appName: "",
|
||||||
autoDeploy: true,
|
autoDeploy: true,
|
||||||
enableSubmodules: false,
|
enableSubmodules: false,
|
||||||
@@ -25,8 +35,10 @@ const baseApp: ApplicationNested = {
|
|||||||
registryUrl: "",
|
registryUrl: "",
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
buildArgs: null,
|
buildArgs: null,
|
||||||
|
buildSecrets: null,
|
||||||
isPreviewDeploymentsActive: false,
|
isPreviewDeploymentsActive: false,
|
||||||
previewBuildArgs: null,
|
previewBuildArgs: null,
|
||||||
|
previewBuildSecrets: null,
|
||||||
triggerType: "push",
|
triggerType: "push",
|
||||||
previewCertificateType: "none",
|
previewCertificateType: "none",
|
||||||
previewEnv: null,
|
previewEnv: null,
|
||||||
@@ -39,6 +51,7 @@ const baseApp: ApplicationNested = {
|
|||||||
environmentId: "",
|
environmentId: "",
|
||||||
environment: {
|
environment: {
|
||||||
env: "",
|
env: "",
|
||||||
|
isDefault: false,
|
||||||
environmentId: "",
|
environmentId: "",
|
||||||
name: "",
|
name: "",
|
||||||
createdAt: "",
|
createdAt: "",
|
||||||
@@ -112,6 +125,7 @@ const baseApp: ApplicationNested = {
|
|||||||
username: null,
|
username: null,
|
||||||
dockerContextPath: null,
|
dockerContextPath: null,
|
||||||
stopGracePeriodSwarm: null,
|
stopGracePeriodSwarm: null,
|
||||||
|
ulimitsSwarm: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseDomain: Domain = {
|
const baseDomain: Domain = {
|
||||||
@@ -261,3 +275,51 @@ test("CertificateType on websecure entrypoint", async () => {
|
|||||||
|
|
||||||
expect(router.tls?.certResolver).toBe("letsencrypt");
|
expect(router.tls?.certResolver).toBe("letsencrypt");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 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("тест.рф");
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,13 +7,22 @@ export default defineConfig({
|
|||||||
include: ["__test__/**/*.test.ts"], // Incluir solo los archivos de test en el directorio __test__
|
include: ["__test__/**/*.test.ts"], // Incluir solo los archivos de test en el directorio __test__
|
||||||
exclude: ["**/node_modules/**", "**/dist/**", "**/.docker/**"],
|
exclude: ["**/node_modules/**", "**/dist/**", "**/.docker/**"],
|
||||||
pool: "forks",
|
pool: "forks",
|
||||||
|
setupFiles: [path.resolve(__dirname, "setup.ts")],
|
||||||
},
|
},
|
||||||
define: {
|
define: {
|
||||||
"process.env": {
|
"process.env": {
|
||||||
NODE: "test",
|
NODE: "test",
|
||||||
|
GITHUB_CLIENT_ID: "test",
|
||||||
|
GITHUB_CLIENT_SECRET: "test",
|
||||||
|
GOOGLE_CLIENT_ID: "test",
|
||||||
|
GOOGLE_CLIENT_SECRET: "test",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [tsconfigPaths()],
|
plugins: [
|
||||||
|
tsconfigPaths({
|
||||||
|
projects: [path.resolve(__dirname, "../tsconfig.json")],
|
||||||
|
}),
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@dokploy/server": path.resolve(
|
"@dokploy/server": path.resolve(
|
||||||
|
|||||||
81
apps/dokploy/__test__/wss/readValidDirectory.test.ts
Normal file
81
apps/dokploy/__test__/wss/readValidDirectory.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
132
apps/dokploy/__test__/wss/utils.test.ts
Normal file
132
apps/dokploy/__test__/wss/utils.test.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
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,7 +37,7 @@ import { AddSwarmSettings } from "./modify-swarm-settings";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
type: "application" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
|
||||||
}
|
}
|
||||||
|
|
||||||
const AddRedirectchema = z.object({
|
const AddRedirectchema = z.object({
|
||||||
@@ -49,15 +49,15 @@ 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,15 +65,16 @@ 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, isLoading } = mutationMap[type]
|
const { mutateAsync, isPending } = mutationMap[type]
|
||||||
? mutationMap[type]()
|
? mutationMap[type]()
|
||||||
: api.mongo.update.useMutation();
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
@@ -105,11 +106,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 || "",
|
||||||
postgresId: id || "",
|
|
||||||
redisId: id || "",
|
|
||||||
mysqlId: id || "",
|
|
||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
mongoId: id || "",
|
mongoId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
...(type === "application"
|
...(type === "application"
|
||||||
? {
|
? {
|
||||||
registryId:
|
registryId:
|
||||||
@@ -236,7 +237,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button isLoading={isLoading} type="submit" className="w-fit">
|
<Button isLoading={isPending} type="submit" className="w-fit">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const endpointSpecFormSchema = z.object({
|
||||||
|
Mode: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface EndpointSpecFormProps {
|
||||||
|
id: string;
|
||||||
|
type:
|
||||||
|
| "postgres"
|
||||||
|
| "mariadb"
|
||||||
|
| "mongo"
|
||||||
|
| "mysql"
|
||||||
|
| "redis"
|
||||||
|
| "application"
|
||||||
|
| "libsql";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: 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 }),
|
||||||
|
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.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(),
|
||||||
|
libsql: () => api.libsql.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(endpointSpecFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
Mode: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.endpointSpecSwarm) {
|
||||||
|
const es = data.endpointSpecSwarm;
|
||||||
|
form.reset({
|
||||||
|
Mode: es.Mode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: z.infer<typeof endpointSpecFormSchema>) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Check if all values are empty, if so, send null to clear the database
|
||||||
|
const hasAnyValue =
|
||||||
|
formData.Mode !== undefined &&
|
||||||
|
formData.Mode !== null &&
|
||||||
|
formData.Mode !== "";
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
libsqlId: id || "",
|
||||||
|
endpointSpecSwarm: hasAnyValue ? formData : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Endpoint spec updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating endpoint spec");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Mode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Mode</FormLabel>
|
||||||
|
<FormDescription>Endpoint mode (vip or dnsrr)</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select endpoint mode" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="vip">VIP (Virtual IP)</SelectItem>
|
||||||
|
<SelectItem value="dnsrr">DNS Round Robin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
Mode: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Endpoint Spec
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const healthCheckFormSchema = z.object({
|
||||||
|
Test: z.array(z.string()).optional(),
|
||||||
|
Interval: z.coerce.number().optional(),
|
||||||
|
Timeout: z.coerce.number().optional(),
|
||||||
|
StartPeriod: z.coerce.number().optional(),
|
||||||
|
Retries: z.coerce.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface HealthCheckFormProps {
|
||||||
|
id: string;
|
||||||
|
type:
|
||||||
|
| "postgres"
|
||||||
|
| "mariadb"
|
||||||
|
| "mongo"
|
||||||
|
| "mysql"
|
||||||
|
| "redis"
|
||||||
|
| "application"
|
||||||
|
| "libsql";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: 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 }),
|
||||||
|
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.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(),
|
||||||
|
libsql: () => api.libsql.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(healthCheckFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
Test: [],
|
||||||
|
Interval: undefined,
|
||||||
|
Timeout: undefined,
|
||||||
|
StartPeriod: undefined,
|
||||||
|
Retries: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCommands = form.watch("Test") || [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.healthCheckSwarm) {
|
||||||
|
const hc = data.healthCheckSwarm;
|
||||||
|
form.reset({
|
||||||
|
Test: hc.Test || [],
|
||||||
|
Interval: hc.Interval,
|
||||||
|
Timeout: hc.Timeout,
|
||||||
|
StartPeriod: hc.StartPeriod,
|
||||||
|
Retries: hc.Retries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: z.infer<typeof healthCheckFormSchema>) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Check if all values are empty, if so, send null to clear the database
|
||||||
|
const hasAnyValue =
|
||||||
|
(formData.Test && formData.Test.length > 0) ||
|
||||||
|
formData.Interval !== undefined ||
|
||||||
|
formData.Timeout !== undefined ||
|
||||||
|
formData.StartPeriod !== undefined ||
|
||||||
|
formData.Retries !== undefined;
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
libsqlId: id || "",
|
||||||
|
healthCheckSwarm: hasAnyValue ? formData : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Health check updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating health check");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTestCommand = () => {
|
||||||
|
form.setValue("Test", [...testCommands, ""]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTestCommand = (index: number, value: string) => {
|
||||||
|
const newCommands = [...testCommands];
|
||||||
|
newCommands[index] = value;
|
||||||
|
form.setValue("Test", newCommands);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTestCommand = (index: number) => {
|
||||||
|
form.setValue(
|
||||||
|
"Test",
|
||||||
|
testCommands.filter((_: string, i: number) => i !== index),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<FormLabel>Test Commands</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Command to run for health check (e.g., ["CMD-SHELL", "curl -f
|
||||||
|
http://localhost:3000/health"])
|
||||||
|
</FormDescription>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{testCommands.map((cmd: string, index: number) => (
|
||||||
|
<div key={index} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={cmd}
|
||||||
|
onChange={(e) => updateTestCommand(index, e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
index === 0
|
||||||
|
? "CMD-SHELL"
|
||||||
|
: "curl -f http://localhost:3000/health"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeTestCommand(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addTestCommand}
|
||||||
|
>
|
||||||
|
Add Command
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Interval"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Interval (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Time between health checks (e.g., 10000000000 for 10 seconds)
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Timeout"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Timeout (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Maximum time to wait for health check response
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="StartPeriod"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Start Period (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Initial grace period before health checks begin
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Retries"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Retries</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Number of consecutive failures needed to consider container
|
||||||
|
unhealthy
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="3" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
Test: [],
|
||||||
|
Interval: undefined,
|
||||||
|
Timeout: undefined,
|
||||||
|
StartPeriod: undefined,
|
||||||
|
Retries: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Health Check
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export { EndpointSpecForm } from "./endpoint-spec-form";
|
||||||
|
export { HealthCheckForm } from "./health-check-form";
|
||||||
|
export { LabelsForm } from "./labels-form";
|
||||||
|
export { ModeForm } from "./mode-form";
|
||||||
|
export { NetworkForm } from "./network-form";
|
||||||
|
export { PlacementForm } from "./placement-form";
|
||||||
|
export { RestartPolicyForm } from "./restart-policy-form";
|
||||||
|
export { RollbackConfigForm } from "./rollback-config-form";
|
||||||
|
export { StopGracePeriodForm } from "./stop-grace-period-form";
|
||||||
|
export { UpdateConfigForm } from "./update-config-form";
|
||||||
|
export { filterEmptyValues, hasValues } from "./utils";
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const labelsFormSchema = z.object({
|
||||||
|
labels: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
key: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface LabelsFormProps {
|
||||||
|
id: string;
|
||||||
|
type:
|
||||||
|
| "postgres"
|
||||||
|
| "mariadb"
|
||||||
|
| "mongo"
|
||||||
|
| "mysql"
|
||||||
|
| "redis"
|
||||||
|
| "application"
|
||||||
|
| "libsql";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: 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 }),
|
||||||
|
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.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(),
|
||||||
|
libsql: () => api.libsql.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(labelsFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
labels: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "labels",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.labelsSwarm && typeof data.labelsSwarm === "object") {
|
||||||
|
const labelEntries = Object.entries(data.labelsSwarm).map(
|
||||||
|
([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value: value as string,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
form.reset({ labels: labelEntries });
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: z.infer<typeof labelsFormSchema>) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const labelsObject =
|
||||||
|
formData.labels?.reduce(
|
||||||
|
(acc, { key, value }) => {
|
||||||
|
if (key && value) {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>,
|
||||||
|
) || {};
|
||||||
|
|
||||||
|
// If no labels, send null to clear the database
|
||||||
|
const labelsToSend =
|
||||||
|
Object.keys(labelsObject).length > 0 ? labelsObject : null;
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
libsqlId: id || "",
|
||||||
|
labelsSwarm: labelsToSend,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Labels updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating labels");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<FormLabel>Labels</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Add key-value labels to your service
|
||||||
|
</FormDescription>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div key={field.id} className="flex gap-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`labels.${index}.key`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="com.example.app.name" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`labels.${index}.value`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="my-app" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => append({ key: "", value: "" })}
|
||||||
|
>
|
||||||
|
Add Label
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({ labels: [] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Labels
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
interface ModeFormProps {
|
||||||
|
id: string;
|
||||||
|
type:
|
||||||
|
| "postgres"
|
||||||
|
| "mariadb"
|
||||||
|
| "mongo"
|
||||||
|
| "mysql"
|
||||||
|
| "redis"
|
||||||
|
| "application"
|
||||||
|
| "libsql";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: 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 }),
|
||||||
|
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.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(),
|
||||||
|
libsql: () => api.libsql.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
defaultValues: {
|
||||||
|
type: undefined,
|
||||||
|
Replicas: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const modeType = form.watch("type");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.modeSwarm) {
|
||||||
|
const mode = data.modeSwarm;
|
||||||
|
if (mode.Replicated) {
|
||||||
|
form.reset({
|
||||||
|
type: "Replicated",
|
||||||
|
Replicas: mode.Replicated.Replicas,
|
||||||
|
});
|
||||||
|
} else if (mode.Global) {
|
||||||
|
form.reset({
|
||||||
|
type: "Global",
|
||||||
|
Replicas: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: any) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// If no type is selected, send null to clear the database
|
||||||
|
if (!formData.type) {
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
libsqlId: id || "",
|
||||||
|
modeSwarm: null,
|
||||||
|
});
|
||||||
|
toast.success("Mode updated successfully");
|
||||||
|
refetch();
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeData =
|
||||||
|
formData.type === "Replicated"
|
||||||
|
? {
|
||||||
|
Replicated: {
|
||||||
|
Replicas:
|
||||||
|
formData.Replicas !== undefined && formData.Replicas !== ""
|
||||||
|
? Number(formData.Replicas)
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { Global: {} };
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
libsqlId: id || "",
|
||||||
|
modeSwarm: modeData,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Mode updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating mode");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="type"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Mode Type</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Choose between replicated or global service mode
|
||||||
|
</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select mode type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Replicated">Replicated</SelectItem>
|
||||||
|
<SelectItem value="Global">Global</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{modeType === "Replicated" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Replicas"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Replicas</FormLabel>
|
||||||
|
<FormDescription>Number of replicas to run</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="1" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
type: undefined,
|
||||||
|
Replicas: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Mode
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const driverOptEntrySchema = z.object({
|
||||||
|
key: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const networkFormSchema = z.object({
|
||||||
|
networks: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
Target: z.string().optional(),
|
||||||
|
Aliases: z.string().optional(),
|
||||||
|
DriverOptsEntries: z.array(driverOptEntrySchema).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface NetworkFormProps {
|
||||||
|
id: string;
|
||||||
|
type:
|
||||||
|
| "postgres"
|
||||||
|
| "mariadb"
|
||||||
|
| "mongo"
|
||||||
|
| "mysql"
|
||||||
|
| "redis"
|
||||||
|
| "application"
|
||||||
|
| "libsql";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: 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 }),
|
||||||
|
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.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(),
|
||||||
|
libsql: () => api.libsql.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof networkFormSchema>>({
|
||||||
|
resolver: zodResolver(networkFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
networks: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "networks",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.networkSwarm && Array.isArray(data.networkSwarm)) {
|
||||||
|
const networkEntries = data.networkSwarm.map((network) => ({
|
||||||
|
Target: network.Target || "",
|
||||||
|
Aliases: network.Aliases?.join(", ") || "",
|
||||||
|
DriverOptsEntries: network.DriverOpts
|
||||||
|
? Object.entries(network.DriverOpts).map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value: value ?? "",
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
}));
|
||||||
|
form.reset({ networks: networkEntries });
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: z.infer<typeof networkFormSchema>) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const networksArray =
|
||||||
|
formData.networks
|
||||||
|
?.filter((network) => network.Target)
|
||||||
|
.map((network) => {
|
||||||
|
const entries = (network.DriverOptsEntries ?? []).filter(
|
||||||
|
(e) => e.key.trim() !== "",
|
||||||
|
);
|
||||||
|
const driverOpts =
|
||||||
|
entries.length > 0
|
||||||
|
? Object.fromEntries(
|
||||||
|
entries.map((e) => [e.key.trim(), e.value]),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
Target: network.Target,
|
||||||
|
Aliases: network.Aliases
|
||||||
|
? network.Aliases.split(",").map((alias) => alias.trim())
|
||||||
|
: undefined,
|
||||||
|
DriverOpts: driverOpts,
|
||||||
|
};
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
// If no networks, send null to clear the database
|
||||||
|
const networksToSend = networksArray.length > 0 ? networksArray : null;
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
libsqlId: id || "",
|
||||||
|
networkSwarm: networksToSend,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Network configuration updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating network configuration");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<FormLabel>Networks</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Configure network attachments for your service
|
||||||
|
</FormDescription>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div key={field.id} className="space-y-2 p-3 border rounded">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`networks.${index}.Target`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Network Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="my-network" />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
The name of the network to attach to
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`networks.${index}.Aliases`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Aliases (optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="alias1, alias2, alias3"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Comma-separated list of network aliases
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel>Driver options (optional)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
e.g. com.docker.network.driver.mtu,
|
||||||
|
com.docker.network.driver.host_binding
|
||||||
|
</FormDescription>
|
||||||
|
{(
|
||||||
|
form.watch(`networks.${index}.DriverOptsEntries`) ?? []
|
||||||
|
).map((_, optIndex) => (
|
||||||
|
<div
|
||||||
|
key={optIndex}
|
||||||
|
className="flex gap-2 items-end flex-wrap"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`networks.${index}.DriverOptsEntries.${optIndex}.key`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1 min-w-[140px]">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="com.docker.network.driver.mtu"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`networks.${index}.DriverOptsEntries.${optIndex}.value`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1 min-w-[100px]">
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="1500" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const entries =
|
||||||
|
form.getValues(
|
||||||
|
`networks.${index}.DriverOptsEntries`,
|
||||||
|
) ?? [];
|
||||||
|
form.setValue(
|
||||||
|
`networks.${index}.DriverOptsEntries`,
|
||||||
|
entries.filter((_, i) => i !== optIndex),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const entries =
|
||||||
|
form.getValues(`networks.${index}.DriverOptsEntries`) ??
|
||||||
|
[];
|
||||||
|
form.setValue(`networks.${index}.DriverOptsEntries`, [
|
||||||
|
...entries,
|
||||||
|
{ key: "", value: "" },
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add driver option
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
>
|
||||||
|
Remove Network
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
append({
|
||||||
|
Target: "",
|
||||||
|
Aliases: "",
|
||||||
|
DriverOptsEntries: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Add Network
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({ networks: [] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Networks
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const PreferenceSchema = z.object({
|
||||||
|
SpreadDescriptor: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PlatformSchema = z.object({
|
||||||
|
Architecture: z.string(),
|
||||||
|
OS: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const placementFormSchema = z.object({
|
||||||
|
Constraints: z.array(z.string()).optional(),
|
||||||
|
Preferences: z.array(PreferenceSchema).optional(),
|
||||||
|
MaxReplicas: z.coerce.number().optional(),
|
||||||
|
Platforms: z.array(PlatformSchema).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface PlacementFormProps {
|
||||||
|
id: string;
|
||||||
|
type:
|
||||||
|
| "postgres"
|
||||||
|
| "mariadb"
|
||||||
|
| "mongo"
|
||||||
|
| "mysql"
|
||||||
|
| "redis"
|
||||||
|
| "application"
|
||||||
|
| "libsql";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: 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 }),
|
||||||
|
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.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(),
|
||||||
|
libsql: () => api.libsql.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(placementFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
Constraints: [],
|
||||||
|
Preferences: [],
|
||||||
|
MaxReplicas: undefined,
|
||||||
|
Platforms: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const constraints = form.watch("Constraints") || [];
|
||||||
|
const preferences = form.watch("Preferences") || [];
|
||||||
|
const platforms = form.watch("Platforms") || [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.placementSwarm) {
|
||||||
|
const placement = data.placementSwarm;
|
||||||
|
form.reset({
|
||||||
|
Constraints: placement.Constraints || [],
|
||||||
|
Preferences:
|
||||||
|
placement.Preferences?.map((p: any) => ({
|
||||||
|
SpreadDescriptor: p.Spread?.SpreadDescriptor || "",
|
||||||
|
})) || [],
|
||||||
|
MaxReplicas: placement.MaxReplicas,
|
||||||
|
Platforms: placement.Platforms || [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: z.infer<typeof placementFormSchema>) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Check if all values are empty, if so, send null to clear the database
|
||||||
|
const hasAnyValue =
|
||||||
|
(formData.Constraints && formData.Constraints.length > 0) ||
|
||||||
|
(formData.Preferences && formData.Preferences.length > 0) ||
|
||||||
|
(formData.Platforms && formData.Platforms.length > 0) ||
|
||||||
|
formData.MaxReplicas !== undefined;
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
libsqlId: id || "",
|
||||||
|
placementSwarm: hasAnyValue
|
||||||
|
? {
|
||||||
|
...formData,
|
||||||
|
Preferences: formData.Preferences?.map((p) => ({
|
||||||
|
Spread: { SpreadDescriptor: p.SpreadDescriptor },
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Placement updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating placement");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addConstraint = () => {
|
||||||
|
form.setValue("Constraints", [...constraints, ""]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConstraint = (index: number, value: string) => {
|
||||||
|
const newConstraints = [...constraints];
|
||||||
|
newConstraints[index] = value;
|
||||||
|
form.setValue("Constraints", newConstraints);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeConstraint = (index: number) => {
|
||||||
|
form.setValue(
|
||||||
|
"Constraints",
|
||||||
|
constraints.filter((_: string, i: number) => i !== index),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPreference = () => {
|
||||||
|
form.setValue("Preferences", [...preferences, { SpreadDescriptor: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePreference = (index: number, value: string) => {
|
||||||
|
const newPreferences = [...preferences];
|
||||||
|
if (newPreferences[index]) {
|
||||||
|
newPreferences[index].SpreadDescriptor = value;
|
||||||
|
form.setValue("Preferences", newPreferences);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePreference = (index: number) => {
|
||||||
|
form.setValue(
|
||||||
|
"Preferences",
|
||||||
|
preferences.filter((_: any, i: number) => i !== index),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPlatform = () => {
|
||||||
|
form.setValue("Platforms", [...platforms, { Architecture: "", OS: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePlatform = (
|
||||||
|
index: number,
|
||||||
|
field: "Architecture" | "OS",
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const newPlatforms = [...platforms];
|
||||||
|
if (newPlatforms[index]) {
|
||||||
|
newPlatforms[index][field] = value;
|
||||||
|
form.setValue("Platforms", newPlatforms);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePlatform = (index: number) => {
|
||||||
|
form.setValue(
|
||||||
|
"Platforms",
|
||||||
|
platforms.filter((_: any, i: number) => i !== index),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<FormLabel>Constraints</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Placement constraints (e.g., "node.role==manager")
|
||||||
|
</FormDescription>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{constraints.map((constraint: string, index: number) => (
|
||||||
|
<div key={index} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={constraint}
|
||||||
|
onChange={(e) => updateConstraint(index, e.target.value)}
|
||||||
|
placeholder="node.role==manager"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeConstraint(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addConstraint}
|
||||||
|
>
|
||||||
|
Add Constraint
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormLabel>Preferences</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Spread preferences for task distribution (e.g.,
|
||||||
|
"node.labels.region")
|
||||||
|
</FormDescription>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{preferences.map((pref: any, index: number) => (
|
||||||
|
<div key={index} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={pref.SpreadDescriptor}
|
||||||
|
onChange={(e) => updatePreference(index, e.target.value)}
|
||||||
|
placeholder="node.labels.region"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removePreference(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addPreference}
|
||||||
|
>
|
||||||
|
Add Preference
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="MaxReplicas"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Max Replicas</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Maximum number of replicas per node
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormLabel>Platforms</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Target platforms for task scheduling
|
||||||
|
</FormDescription>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{platforms.map((platform: any, index: number) => (
|
||||||
|
<div key={index} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={platform.Architecture}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePlatform(index, "Architecture", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="amd64"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={platform.OS}
|
||||||
|
onChange={(e) => updatePlatform(index, "OS", e.target.value)}
|
||||||
|
placeholder="linux"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removePlatform(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addPlatform}
|
||||||
|
>
|
||||||
|
Add Platform
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
Constraints: [],
|
||||||
|
Preferences: [],
|
||||||
|
MaxReplicas: undefined,
|
||||||
|
Platforms: [],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Placement
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const restartPolicyFormSchema = z.object({
|
||||||
|
Condition: z.string().optional(),
|
||||||
|
Delay: z.coerce.number().optional(),
|
||||||
|
MaxAttempts: z.coerce.number().optional(),
|
||||||
|
Window: z.coerce.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RestartPolicyFormProps {
|
||||||
|
id: string;
|
||||||
|
type:
|
||||||
|
| "postgres"
|
||||||
|
| "mariadb"
|
||||||
|
| "mongo"
|
||||||
|
| "mysql"
|
||||||
|
| "redis"
|
||||||
|
| "application"
|
||||||
|
| "libsql";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: 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 }),
|
||||||
|
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.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(),
|
||||||
|
libsql: () => api.libsql.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(restartPolicyFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
Condition: undefined,
|
||||||
|
Delay: undefined,
|
||||||
|
MaxAttempts: undefined,
|
||||||
|
Window: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.restartPolicySwarm) {
|
||||||
|
form.reset({
|
||||||
|
Condition: data.restartPolicySwarm.Condition,
|
||||||
|
Delay: data.restartPolicySwarm.Delay,
|
||||||
|
MaxAttempts: data.restartPolicySwarm.MaxAttempts,
|
||||||
|
Window: data.restartPolicySwarm.Window,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (
|
||||||
|
formData: z.infer<typeof restartPolicyFormSchema>,
|
||||||
|
) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Check if all values are empty, if so, send null to clear the database
|
||||||
|
const hasAnyValue = Object.values(formData).some(
|
||||||
|
(value) => value !== undefined && value !== null && value !== "",
|
||||||
|
);
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
libsqlId: id || "",
|
||||||
|
restartPolicySwarm: hasAnyValue ? formData : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Restart policy updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating restart policy");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Condition"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Condition</FormLabel>
|
||||||
|
<FormDescription>When to restart the container</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select restart condition" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
<SelectItem value="on-failure">On Failure</SelectItem>
|
||||||
|
<SelectItem value="any">Any</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Delay"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Delay (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Wait time between restart attempts
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="MaxAttempts"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Max Attempts</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Maximum number of restart attempts
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="3" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Window"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Window (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Time window to evaluate restart policy
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
Condition: undefined,
|
||||||
|
Delay: undefined,
|
||||||
|
MaxAttempts: undefined,
|
||||||
|
Window: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Restart Policy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const rollbackConfigFormSchema = z.object({
|
||||||
|
Parallelism: z.coerce.number().optional(),
|
||||||
|
Delay: z.coerce.number().optional(),
|
||||||
|
FailureAction: z.string().optional(),
|
||||||
|
Monitor: z.coerce.number().optional(),
|
||||||
|
MaxFailureRatio: z.coerce.number().optional(),
|
||||||
|
Order: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RollbackConfigFormProps {
|
||||||
|
id: string;
|
||||||
|
type:
|
||||||
|
| "postgres"
|
||||||
|
| "mariadb"
|
||||||
|
| "mongo"
|
||||||
|
| "mysql"
|
||||||
|
| "redis"
|
||||||
|
| "application"
|
||||||
|
| "libsql";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: 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 }),
|
||||||
|
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.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(),
|
||||||
|
libsql: () => api.libsql.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(rollbackConfigFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
Parallelism: undefined,
|
||||||
|
Delay: undefined,
|
||||||
|
FailureAction: undefined,
|
||||||
|
Monitor: undefined,
|
||||||
|
MaxFailureRatio: undefined,
|
||||||
|
Order: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.rollbackConfigSwarm) {
|
||||||
|
form.reset(data.rollbackConfigSwarm);
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (
|
||||||
|
formData: z.infer<typeof rollbackConfigFormSchema>,
|
||||||
|
) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Check if all values are empty, if so, send null to clear the database
|
||||||
|
const hasAnyValue = Object.values(formData).some(
|
||||||
|
(value) => value !== undefined && value !== null && value !== "",
|
||||||
|
);
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
libsqlId: id || "",
|
||||||
|
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Rollback config updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating rollback config");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Parallelism"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Parallelism</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Number of tasks to rollback simultaneously
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="1" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Delay"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Delay (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>Delay between task rollbacks</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="FailureAction"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Failure Action</FormLabel>
|
||||||
|
<FormDescription>Action on rollback failure</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select failure action" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pause">Pause</SelectItem>
|
||||||
|
<SelectItem value="continue">Continue</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Monitor"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Monitor (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Duration to monitor for failure after rollback
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="MaxFailureRatio"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Max Failure Ratio</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Maximum failure ratio tolerated (0-1)
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" step="0.01" placeholder="0.1" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Order"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Order</FormLabel>
|
||||||
|
<FormDescription>Rollback order strategy</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select order" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="stop-first">Stop First</SelectItem>
|
||||||
|
<SelectItem value="start-first">Start First</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
Parallelism: undefined,
|
||||||
|
Delay: undefined,
|
||||||
|
FailureAction: undefined,
|
||||||
|
Monitor: undefined,
|
||||||
|
MaxFailureRatio: undefined,
|
||||||
|
Order: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Rollback Config
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const hasStopGracePeriodSwarm = (
|
||||||
|
value: unknown,
|
||||||
|
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"stopGracePeriodSwarm" in value;
|
||||||
|
|
||||||
|
interface StopGracePeriodFormProps {
|
||||||
|
id: string;
|
||||||
|
type:
|
||||||
|
| "postgres"
|
||||||
|
| "mariadb"
|
||||||
|
| "mongo"
|
||||||
|
| "mysql"
|
||||||
|
| "redis"
|
||||||
|
| "application"
|
||||||
|
| "libsql";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: 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 }),
|
||||||
|
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.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(),
|
||||||
|
libsql: () => api.libsql.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
defaultValues: {
|
||||||
|
value: null as bigint | null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasStopGracePeriodSwarm(data)) {
|
||||||
|
const value = data.stopGracePeriodSwarm;
|
||||||
|
const normalizedValue =
|
||||||
|
value === null || value === undefined
|
||||||
|
? null
|
||||||
|
: typeof value === "bigint"
|
||||||
|
? value
|
||||||
|
: BigInt(value);
|
||||||
|
form.reset({
|
||||||
|
value: normalizedValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: any) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
libsqlId: id || "",
|
||||||
|
stopGracePeriodSwarm: formData.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Stop grace period updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating stop grace period");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="value"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Stop Grace Period (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Time to wait before forcefully killing the container
|
||||||
|
<br />
|
||||||
|
Examples: 30000000000 (30s), 120000000000 (2m)
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="30000000000"
|
||||||
|
{...field}
|
||||||
|
value={
|
||||||
|
field?.value !== null && field?.value !== undefined
|
||||||
|
? field.value.toString()
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(
|
||||||
|
e.target.value ? BigInt(e.target.value) : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
value: null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Stop Grace Period
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const updateConfigFormSchema = z.object({
|
||||||
|
Parallelism: z.coerce.number().optional(),
|
||||||
|
Delay: z.coerce.number().optional(),
|
||||||
|
FailureAction: z.string().optional(),
|
||||||
|
Monitor: z.coerce.number().optional(),
|
||||||
|
MaxFailureRatio: z.coerce.number().optional(),
|
||||||
|
Order: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface UpdateConfigFormProps {
|
||||||
|
id: string;
|
||||||
|
type:
|
||||||
|
| "postgres"
|
||||||
|
| "mariadb"
|
||||||
|
| "mongo"
|
||||||
|
| "mysql"
|
||||||
|
| "redis"
|
||||||
|
| "application"
|
||||||
|
| "libsql";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: 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 }),
|
||||||
|
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.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(),
|
||||||
|
libsql: () => api.libsql.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(updateConfigFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
Parallelism: undefined,
|
||||||
|
Delay: undefined,
|
||||||
|
FailureAction: undefined,
|
||||||
|
Monitor: undefined,
|
||||||
|
MaxFailureRatio: undefined,
|
||||||
|
Order: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.updateConfigSwarm) {
|
||||||
|
const config = data.updateConfigSwarm;
|
||||||
|
form.reset({
|
||||||
|
Parallelism: config.Parallelism,
|
||||||
|
Delay: config.Delay,
|
||||||
|
FailureAction: config.FailureAction,
|
||||||
|
Monitor: config.Monitor,
|
||||||
|
MaxFailureRatio: config.MaxFailureRatio,
|
||||||
|
Order: config.Order,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: z.infer<typeof updateConfigFormSchema>) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Check if all values are empty, if so, send null to clear the database
|
||||||
|
const hasAnyValue = Object.values(formData).some(
|
||||||
|
(value) => value !== undefined && value !== null && value !== "",
|
||||||
|
);
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
libsqlId: id || "",
|
||||||
|
updateConfigSwarm: (hasAnyValue ? formData : null) as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Update config updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating update config");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Parallelism"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Parallelism</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Number of tasks to update simultaneously
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="1" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Delay"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Delay (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>Delay between task updates</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="FailureAction"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Failure Action</FormLabel>
|
||||||
|
<FormDescription>Action on update failure</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select failure action" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pause">Pause</SelectItem>
|
||||||
|
<SelectItem value="continue">Continue</SelectItem>
|
||||||
|
<SelectItem value="rollback">Rollback</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Monitor"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Monitor (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Duration to monitor for failure after update
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="MaxFailureRatio"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Max Failure Ratio</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Maximum failure ratio tolerated (0-1)
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" step="0.01" placeholder="0.1" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Order"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Order</FormLabel>
|
||||||
|
<FormDescription>Update order strategy</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select order" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="stop-first">Stop First</SelectItem>
|
||||||
|
<SelectItem value="start-first">Start First</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
Parallelism: undefined,
|
||||||
|
Delay: undefined,
|
||||||
|
FailureAction: undefined,
|
||||||
|
Monitor: undefined,
|
||||||
|
MaxFailureRatio: undefined,
|
||||||
|
Order: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Update Config
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Filters out undefined, null, and empty string values from form data
|
||||||
|
* Only returns fields that have actual values
|
||||||
|
*/
|
||||||
|
export const filterEmptyValues = (
|
||||||
|
formData: Record<string, any>,
|
||||||
|
): Record<string, any> => {
|
||||||
|
return Object.entries(formData).reduce(
|
||||||
|
(acc, [key, value]) => {
|
||||||
|
// Keep arrays even if empty (they might be intentionally cleared)
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length > 0) {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For other values, filter out undefined, null, and empty strings
|
||||||
|
else if (value !== undefined && value !== null && value !== "") {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, any>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if filtered data has any values to save
|
||||||
|
*/
|
||||||
|
export const hasValues = (data: Record<string, any>): boolean => {
|
||||||
|
return Object.keys(data).length > 0;
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -28,6 +29,13 @@ interface Props {
|
|||||||
|
|
||||||
const AddRedirectSchema = z.object({
|
const AddRedirectSchema = z.object({
|
||||||
command: z.string(),
|
command: z.string(),
|
||||||
|
args: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
value: z.string().min(1, "Argument cannot be empty"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
||||||
@@ -42,27 +50,35 @@ export const AddCommand = ({ applicationId }: Props) => {
|
|||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const { mutateAsync, isLoading } = api.application.update.useMutation();
|
const { mutateAsync, isPending } = api.application.update.useMutation();
|
||||||
|
|
||||||
const form = useForm<AddCommand>({
|
const form = useForm<AddCommand>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
command: "",
|
command: "",
|
||||||
|
args: [],
|
||||||
},
|
},
|
||||||
resolver: zodResolver(AddRedirectSchema),
|
resolver: zodResolver(AddRedirectSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "args",
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.command) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
command: data?.command || "",
|
command: data?.command || "",
|
||||||
|
args: data?.args?.map((arg) => ({ value: arg })) || [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
|
}, [data, form]);
|
||||||
|
|
||||||
const onSubmit = async (data: AddCommand) => {
|
const onSubmit = async (data: AddCommand) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
applicationId,
|
applicationId,
|
||||||
command: data?.command,
|
command: data?.command,
|
||||||
|
args: data?.args?.map((arg) => arg.value).filter(Boolean),
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Command Updated");
|
toast.success("Command Updated");
|
||||||
@@ -100,16 +116,68 @@ export const AddCommand = ({ applicationId }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Command</FormLabel>
|
<FormLabel>Command</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Custom command" {...field} />
|
<Input placeholder="/bin/sh" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel>Arguments (Args)</FormLabel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => append({ value: "" })}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add Argument
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fields.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No arguments added yet. Click "Add Argument" to add one.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<FormField
|
||||||
|
key={field.id}
|
||||||
|
control={form.control}
|
||||||
|
name={`args.${index}.value`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={
|
||||||
|
index === 0 ? "-c" : "echo Hello World"
|
||||||
|
}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button isLoading={isLoading} type="submit" className="w-fit">
|
<Button isLoading={isPending} type="submit" className="w-fit">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
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, isLoading: isLoadingTemplate } =
|
const { mutateAsync: processTemplate, isPending: isLoadingTemplate } =
|
||||||
api.compose.processTemplate.useMutation();
|
api.compose.processTemplate.useMutation();
|
||||||
const {
|
const {
|
||||||
mutateAsync: importTemplate,
|
mutateAsync: importTemplate,
|
||||||
isLoading: isImporting,
|
isPending: isImporting,
|
||||||
isSuccess: isImportSuccess,
|
isSuccess: isImportSuccess,
|
||||||
} = api.compose.import.useMutation();
|
} = api.compose.import.useMutation();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
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,13 +35,9 @@ 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>;
|
||||||
@@ -68,7 +64,7 @@ export const HandlePorts = ({
|
|||||||
enabled: !!portId,
|
enabled: !!portId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const { mutateAsync, isLoading, error, isError } = portId
|
const { mutateAsync, isPending, error, isError } = portId
|
||||||
? api.port.update.useMutation()
|
? api.port.update.useMutation()
|
||||||
: api.port.create.useMutation();
|
: api.port.create.useMutation();
|
||||||
|
|
||||||
@@ -270,7 +266,7 @@ export const HandlePorts = ({
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoading}
|
isLoading={isPending}
|
||||||
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, isLoading: isRemoving } =
|
const { mutateAsync: deletePort, isPending: isRemoving } =
|
||||||
api.port.delete.useMutation();
|
api.port.delete.useMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
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";
|
||||||
@@ -100,11 +100,11 @@ export const HandleRedirect = ({
|
|||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const { mutateAsync, isLoading, error, isError } = redirectId
|
const { mutateAsync, isPending, error, isError } = redirectId
|
||||||
? api.redirects.update.useMutation()
|
? api.redirects.update.useMutation()
|
||||||
: api.redirects.create.useMutation();
|
: api.redirects.create.useMutation();
|
||||||
|
|
||||||
const form = useForm<AddRedirect>({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
regex: "",
|
regex: "",
|
||||||
@@ -268,7 +268,7 @@ export const HandleRedirect = ({
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoading}
|
isLoading={isPending}
|
||||||
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, isLoading: isRemoving } =
|
const { mutateAsync: deleteRedirect, isPending: isRemoving } =
|
||||||
api.redirects.delete.useMutation();
|
api.redirects.delete.useMutation();
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
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 } = api.security.one.useQuery(
|
const { data, refetch } = api.security.one.useQuery(
|
||||||
{
|
{
|
||||||
securityId: securityId ?? "",
|
securityId: securityId ?? "",
|
||||||
},
|
},
|
||||||
@@ -55,7 +55,7 @@ export const HandleSecurity = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync, isLoading, error, isError } = securityId
|
const { mutateAsync, isPending, error, isError } = securityId
|
||||||
? api.security.update.useMutation()
|
? api.security.update.useMutation()
|
||||||
: api.security.create.useMutation();
|
: api.security.create.useMutation();
|
||||||
|
|
||||||
@@ -88,6 +88,7 @@ export const HandleSecurity = ({
|
|||||||
await utils.application.readTraefikConfig.invalidate({
|
await utils.application.readTraefikConfig.invalidate({
|
||||||
applicationId,
|
applicationId,
|
||||||
});
|
});
|
||||||
|
await refetch();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -163,7 +164,7 @@ export const HandleSecurity = ({
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoading}
|
isLoading={isPending}
|
||||||
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, isLoading: isRemoving } =
|
const { mutateAsync: deleteSecurity, isPending: isRemoving } =
|
||||||
api.security.delete.useMutation();
|
api.security.delete.useMutation();
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
|
import { Server } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
applicationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = z
|
||||||
|
.object({
|
||||||
|
buildServerId: z.string().optional(),
|
||||||
|
buildRegistryId: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
// Both empty/none is valid
|
||||||
|
const buildServerIsNone =
|
||||||
|
!data.buildServerId || data.buildServerId === "none";
|
||||||
|
const buildRegistryIsNone =
|
||||||
|
!data.buildRegistryId || data.buildRegistryId === "none";
|
||||||
|
|
||||||
|
// Both should be either filled or empty
|
||||||
|
if (buildServerIsNone && buildRegistryIsNone) return true;
|
||||||
|
if (!buildServerIsNone && !buildRegistryIsNone) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Both Build Server and Build Registry must be selected together, or both set to None",
|
||||||
|
path: ["buildServerId"], // Show error on buildServerId field
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type Schema = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
export const ShowBuildServer = ({ applicationId }: Props) => {
|
||||||
|
const { data, refetch } = api.application.one.useQuery(
|
||||||
|
{ applicationId },
|
||||||
|
{ enabled: !!applicationId },
|
||||||
|
);
|
||||||
|
const { data: buildServers } = api.server.buildServers.useQuery();
|
||||||
|
const { data: registries } = api.registry.all.useQuery();
|
||||||
|
|
||||||
|
const { mutateAsync, isPending } = api.application.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<Schema>({
|
||||||
|
defaultValues: {
|
||||||
|
buildServerId: data?.buildServerId || "",
|
||||||
|
buildRegistryId: data?.buildRegistryId || "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
form.reset({
|
||||||
|
buildServerId: data?.buildServerId || "",
|
||||||
|
buildRegistryId: data?.buildRegistryId || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [form, form.reset, data]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: Schema) => {
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId,
|
||||||
|
buildServerId:
|
||||||
|
formData?.buildServerId === "none" || !formData?.buildServerId
|
||||||
|
? null
|
||||||
|
: formData?.buildServerId,
|
||||||
|
buildRegistryId:
|
||||||
|
formData?.buildRegistryId === "none" || !formData?.buildRegistryId
|
||||||
|
? null
|
||||||
|
: formData?.buildRegistryId,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Build Server Settings Updated");
|
||||||
|
await refetch();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error updating build server settings");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-background">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<Server className="size-6 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl">Build Server</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure a dedicated server for building your application.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<AlertBlock type="info">
|
||||||
|
Build servers offload the build process from your deployment servers.
|
||||||
|
Select a build server and registry to use for building your
|
||||||
|
application.
|
||||||
|
</AlertBlock>
|
||||||
|
|
||||||
|
<AlertBlock type="info">
|
||||||
|
📊 <strong>Important:</strong> Once the build finishes, you'll need to
|
||||||
|
wait a few seconds for the deployment server to download the image.
|
||||||
|
These download logs will <strong>NOT</strong> appear in the build
|
||||||
|
deployment logs. Check the <strong>Logs</strong> tab to see when the
|
||||||
|
container starts running.
|
||||||
|
</AlertBlock>
|
||||||
|
|
||||||
|
<AlertBlock type="info">
|
||||||
|
<strong>Note:</strong> Build Server and Build Registry must be
|
||||||
|
configured together. You can either select both or set both to None.
|
||||||
|
</AlertBlock>
|
||||||
|
|
||||||
|
{!registries || registries.length === 0 ? (
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
You need to add at least one registry to use build servers. Please
|
||||||
|
go to{" "}
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings/registry"
|
||||||
|
className="text-primary underline"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Link>{" "}
|
||||||
|
to add a registry.
|
||||||
|
</AlertBlock>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="grid w-full gap-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="buildServerId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Build Server</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
// If setting to "none", also reset build registry to "none"
|
||||||
|
if (value === "none") {
|
||||||
|
form.setValue("buildRegistryId", "none");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={field.value || "none"}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a build server" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="none">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>None</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
{buildServers?.map((server) => (
|
||||||
|
<SelectItem
|
||||||
|
key={server.serverId}
|
||||||
|
value={server.serverId}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 justify-between w-full">
|
||||||
|
<span>{server.name}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{server.ipAddress}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectLabel>
|
||||||
|
Build Servers ({buildServers?.length || 0})
|
||||||
|
</SelectLabel>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
Select a build server to handle the build process for this
|
||||||
|
application.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="buildRegistryId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Build Registry</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
// If setting to "none", also reset build server to "none"
|
||||||
|
if (value === "none") {
|
||||||
|
form.setValue("buildServerId", "none");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={field.value || "none"}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a registry" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="none">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>None</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
{registries?.map((registry) => (
|
||||||
|
<SelectItem
|
||||||
|
key={registry.registryId}
|
||||||
|
value={registry.registryId}
|
||||||
|
>
|
||||||
|
{registry.registryName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectLabel>
|
||||||
|
Registries ({registries?.length || 0})
|
||||||
|
</SelectLabel>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
Select a registry to store the built images from the build
|
||||||
|
server.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full justify-end">
|
||||||
|
<Button isLoading={isPending} type="submit">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { InfoIcon } from "lucide-react";
|
import { InfoIcon, Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useFieldArray, 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";
|
||||||
@@ -22,6 +22,17 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
createConverter,
|
||||||
|
NumberInputWithSteps,
|
||||||
|
} from "@/components/ui/number-input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -30,20 +41,61 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const CPU_STEP = 0.25;
|
||||||
|
const MEMORY_STEP_MB = 256;
|
||||||
|
|
||||||
|
const formatNumber = (value: number, decimals = 2): string =>
|
||||||
|
Number.isInteger(value) ? String(value) : value.toFixed(decimals);
|
||||||
|
|
||||||
|
const cpuConverter = createConverter(1_000_000_000, (cpu) =>
|
||||||
|
cpu <= 0 ? "" : `${formatNumber(cpu)} CPU`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const memoryConverter = createConverter(1024 * 1024, (mb) => {
|
||||||
|
if (mb <= 0) return "";
|
||||||
|
return mb >= 1024
|
||||||
|
? `${formatNumber(mb / 1024)} GB`
|
||||||
|
: `${formatNumber(mb)} MB`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ulimitSchema = z.object({
|
||||||
|
Name: z.string().min(1, "Name is required"),
|
||||||
|
Soft: z.coerce.number().int().min(-1, "Must be >= -1"),
|
||||||
|
Hard: z.coerce.number().int().min(-1, "Must be >= -1"),
|
||||||
|
});
|
||||||
|
|
||||||
const addResourcesSchema = z.object({
|
const addResourcesSchema = z.object({
|
||||||
memoryReservation: z.string().optional(),
|
memoryReservation: z.string().optional(),
|
||||||
cpuLimit: z.string().optional(),
|
cpuLimit: z.string().optional(),
|
||||||
memoryLimit: z.string().optional(),
|
memoryLimit: z.string().optional(),
|
||||||
cpuReservation: z.string().optional(),
|
cpuReservation: z.string().optional(),
|
||||||
|
ulimitsSwarm: z.array(ulimitSchema).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ULIMIT_PRESETS = [
|
||||||
|
{ value: "nofile", label: "nofile (Open Files)" },
|
||||||
|
{ value: "nproc", label: "nproc (Processes)" },
|
||||||
|
{ value: "memlock", label: "memlock (Locked Memory)" },
|
||||||
|
{ value: "stack", label: "stack (Stack Size)" },
|
||||||
|
{ value: "core", label: "core (Core File Size)" },
|
||||||
|
{ value: "cpu", label: "cpu (CPU Time)" },
|
||||||
|
{ value: "data", label: "data (Data Segment)" },
|
||||||
|
{ value: "fsize", label: "fsize (File Size)" },
|
||||||
|
{ value: "locks", label: "locks (File Locks)" },
|
||||||
|
{ value: "msgqueue", label: "msgqueue (Message Queues)" },
|
||||||
|
{ value: "nice", label: "nice (Nice Priority)" },
|
||||||
|
{ value: "rtprio", label: "rtprio (Real-time Priority)" },
|
||||||
|
{ value: "sigpending", label: "sigpending (Pending Signals)" },
|
||||||
|
];
|
||||||
|
|
||||||
export type ServiceType =
|
export type ServiceType =
|
||||||
| "postgres"
|
| "application"
|
||||||
| "mongo"
|
| "libsql"
|
||||||
| "redis"
|
|
||||||
| "mysql"
|
|
||||||
| "mariadb"
|
| "mariadb"
|
||||||
| "application";
|
| "mongo"
|
||||||
|
| "mysql"
|
||||||
|
| "postgres"
|
||||||
|
| "redis";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -51,45 +103,54 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AddResources = z.infer<typeof addResourcesSchema>;
|
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, isLoading } = mutationMap[type]
|
const { mutateAsync, isPending } = mutationMap[type]
|
||||||
? mutationMap[type]()
|
? mutationMap[type]()
|
||||||
: api.mongo.update.useMutation();
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
const form = useForm<AddResources>({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
cpuLimit: "",
|
cpuLimit: "",
|
||||||
cpuReservation: "",
|
cpuReservation: "",
|
||||||
memoryLimit: "",
|
memoryLimit: "",
|
||||||
memoryReservation: "",
|
memoryReservation: "",
|
||||||
|
ulimitsSwarm: [],
|
||||||
},
|
},
|
||||||
resolver: zodResolver(addResourcesSchema),
|
resolver: zodResolver(addResourcesSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "ulimitsSwarm",
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -97,22 +158,28 @@ 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 || [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [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,
|
||||||
memoryReservation: formData.memoryReservation || null,
|
memoryReservation: formData.memoryReservation || null,
|
||||||
|
ulimitsSwarm:
|
||||||
|
formData.ulimitsSwarm && formData.ulimitsSwarm.length > 0
|
||||||
|
? formData.ulimitsSwarm
|
||||||
|
: null,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Resources Updated");
|
toast.success("Resources Updated");
|
||||||
@@ -150,7 +217,10 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
<FormLabel>Memory Limit</FormLabel>
|
<FormLabel>Memory Limit</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
@@ -160,16 +230,20 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
Memory hard limit in bytes. Example: 1GB =
|
Memory hard limit in bytes. Example: 1GB =
|
||||||
1073741824 bytes
|
1073741824 bytes. Use +/- buttons to adjust by
|
||||||
|
256 MB.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<NumberInputWithSteps
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
placeholder="1073741824 (1GB in bytes)"
|
placeholder="1073741824 (1GB in bytes)"
|
||||||
{...field}
|
step={MEMORY_STEP_MB}
|
||||||
|
converter={memoryConverter}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -182,7 +256,10 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
name="memoryReservation"
|
name="memoryReservation"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
<FormLabel>Memory Reservation</FormLabel>
|
<FormLabel>Memory Reservation</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
@@ -192,16 +269,20 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
Memory soft limit in bytes. Example: 256MB =
|
Memory soft limit in bytes. Example: 256MB =
|
||||||
268435456 bytes
|
268435456 bytes. Use +/- buttons to adjust by 256
|
||||||
|
MB.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<NumberInputWithSteps
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
placeholder="268435456 (256MB in bytes)"
|
placeholder="268435456 (256MB in bytes)"
|
||||||
{...field}
|
step={MEMORY_STEP_MB}
|
||||||
|
converter={memoryConverter}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -215,7 +296,10 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
<FormLabel>CPU Limit</FormLabel>
|
<FormLabel>CPU Limit</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
@@ -225,17 +309,20 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
CPU quota in units of 10^-9 CPUs. Example: 2
|
CPU quota in units of 10^-9 CPUs. Example: 2
|
||||||
CPUs = 2000000000
|
CPUs = 2000000000. Use +/- buttons to adjust by
|
||||||
|
0.25 CPU.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<NumberInputWithSteps
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
placeholder="2000000000 (2 CPUs)"
|
placeholder="2000000000 (2 CPUs)"
|
||||||
{...field}
|
step={CPU_STEP}
|
||||||
value={field.value?.toString() || ""}
|
converter={cpuConverter}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -249,7 +336,10 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
<FormLabel>CPU Reservation</FormLabel>
|
<FormLabel>CPU Reservation</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
@@ -259,14 +349,21 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
CPU shares (relative weight). Example: 1 CPU =
|
CPU shares (relative weight). Example: 1 CPU =
|
||||||
1000000000
|
1000000000. Use +/- buttons to adjust by 0.25
|
||||||
|
CPU.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="1000000000 (1 CPU)" {...field} />
|
<NumberInputWithSteps
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
placeholder="1000000000 (1 CPU)"
|
||||||
|
step={CPU_STEP}
|
||||||
|
converter={cpuConverter}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -274,8 +371,157 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Ulimits Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FormLabel className="text-base">Ulimits</FormLabel>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
<p>
|
||||||
|
Set resource limits for the container. Each ulimit has
|
||||||
|
a soft limit (warning threshold) and hard limit
|
||||||
|
(maximum allowed). Use -1 for unlimited.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
append({ Name: "nofile", Soft: 65535, Hard: 65535 })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add Ulimit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fields.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div
|
||||||
|
key={field.id}
|
||||||
|
className="flex items-start gap-3 p-3 border rounded-lg bg-muted/30"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`ulimitsSwarm.${index}.Name`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormLabel className="text-xs">Type</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select ulimit" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{ULIMIT_PRESETS.map((preset) => (
|
||||||
|
<SelectItem
|
||||||
|
key={preset.value}
|
||||||
|
value={preset.value}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`ulimitsSwarm.${index}.Soft`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-32">
|
||||||
|
<FormLabel className="text-xs">
|
||||||
|
Soft Limit
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={-1}
|
||||||
|
placeholder="65535"
|
||||||
|
{...field}
|
||||||
|
value={
|
||||||
|
typeof field.value === "number"
|
||||||
|
? field.value
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(Number(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`ulimitsSwarm.${index}.Hard`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-32">
|
||||||
|
<FormLabel className="text-xs">
|
||||||
|
Hard Limit
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={-1}
|
||||||
|
placeholder="65535"
|
||||||
|
{...field}
|
||||||
|
value={
|
||||||
|
typeof field.value === "number"
|
||||||
|
? field.value
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(Number(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="mt-6 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fields.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No ulimits configured. Click "Add Ulimit" to set
|
||||||
|
resource limits.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button isLoading={isLoading} type="submit">
|
<Button isLoading={isPending} type="submit">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,13 +15,17 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
||||||
const { data, isLoading } = api.application.readTraefikConfig.useQuery(
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
const canRead = permissions?.traefikFiles.read ?? false;
|
||||||
|
const { data, isPending } = api.application.readTraefikConfig.useQuery(
|
||||||
{
|
{
|
||||||
applicationId,
|
applicationId,
|
||||||
},
|
},
|
||||||
{ enabled: !!applicationId },
|
{ enabled: !!applicationId && canRead },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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">
|
||||||
@@ -35,7 +39,7 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
{isLoading ? (
|
{isPending ? (
|
||||||
<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 { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
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,6 +7,7 @@ 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,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const UpdateTraefikConfigSchema = z.object({
|
const UpdateTraefikConfigSchema = z.object({
|
||||||
@@ -58,7 +60,10 @@ 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 { data, refetch } = api.application.readTraefikConfig.useQuery(
|
const { data, refetch } = api.application.readTraefikConfig.useQuery(
|
||||||
{
|
{
|
||||||
applicationId,
|
applicationId,
|
||||||
@@ -66,7 +71,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
|||||||
{ enabled: !!applicationId },
|
{ enabled: !!applicationId },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync, isLoading, error, isError } =
|
const { mutateAsync, isPending, error, isError } =
|
||||||
api.application.updateTraefikConfig.useMutation();
|
api.application.updateTraefikConfig.useMutation();
|
||||||
|
|
||||||
const form = useForm<UpdateTraefikConfig>({
|
const form = useForm<UpdateTraefikConfig>({
|
||||||
@@ -85,13 +90,15 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
|||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const onSubmit = async (data: UpdateTraefikConfig) => {
|
const onSubmit = async (data: UpdateTraefikConfig) => {
|
||||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
if (!skipYamlValidation) {
|
||||||
if (!valid) {
|
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||||
form.setError("traefikConfig", {
|
if (!valid) {
|
||||||
type: "manual",
|
form.setError("traefikConfig", {
|
||||||
message: (error as string) || "Invalid YAML",
|
type: "manual",
|
||||||
});
|
message: (error as string) || "Invalid YAML",
|
||||||
return;
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
form.clearErrors("traefikConfig");
|
form.clearErrors("traefikConfig");
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
@@ -116,12 +123,15 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
|||||||
setOpen(open);
|
setOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
form.reset();
|
form.reset();
|
||||||
|
setSkipYamlValidation(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTrigger asChild>
|
{canWrite && (
|
||||||
<Button isLoading={isLoading}>Modify</Button>
|
<DialogTrigger asChild>
|
||||||
</DialogTrigger>
|
<Button isLoading={isPending}>Modify</Button>
|
||||||
|
</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>
|
||||||
@@ -169,9 +179,30 @@ routers:
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter className="flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex flex-col gap-1 w-full sm:w-auto sm:mr-auto">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="skip-yaml-validation-app"
|
||||||
|
checked={skipYamlValidation}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setSkipYamlValidation(checked === true)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="skip-yaml-validation-app"
|
||||||
|
className="text-sm font-normal cursor-pointer"
|
||||||
|
>
|
||||||
|
Skip YAML validation (for Go templating)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Check to save configs with Go templating (e.g.{" "}
|
||||||
|
<code className="text-xs">{"{{range}}"}</code>).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoading}
|
isLoading={isPending}
|
||||||
form="hook-form-update-traefik-config"
|
form="hook-form-update-traefik-config"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
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"
|
||||||
| "postgres"
|
| "compose"
|
||||||
| "redis"
|
| "libsql"
|
||||||
| "mongo"
|
|
||||||
| "redis"
|
|
||||||
| "mysql"
|
|
||||||
| "mariadb"
|
| "mariadb"
|
||||||
| "compose";
|
| "mongo"
|
||||||
|
| "mysql"
|
||||||
|
| "postgres"
|
||||||
|
| "redis";
|
||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,13 @@ const mySchema = z.discriminatedUnion("type", [
|
|||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
type: z.literal("volume"),
|
type: z.literal("volume"),
|
||||||
volumeName: z.string().min(1, "Volume name required"),
|
volumeName: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Volume name required")
|
||||||
|
.regex(
|
||||||
|
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
|
||||||
|
"Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.merge(mountSchema),
|
.merge(mountSchema),
|
||||||
z
|
z
|
||||||
@@ -318,7 +324,7 @@ export const AddVolumes = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="content"
|
name="content"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem className="max-w-full max-w-[45rem]">
|
||||||
<FormLabel>Content</FormLabel>
|
<FormLabel>Content</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -327,7 +333,7 @@ export const AddVolumes = ({
|
|||||||
placeholder={`NODE_ENV=production
|
placeholder={`NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3000
|
||||||
`}
|
`}
|
||||||
className="h-96 font-mono"
|
className="h-96 font-mono "
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -21,24 +21,33 @@ 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, isLoading: isRemoving } =
|
const { mutateAsync: deleteVolume, isPending: 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">
|
||||||
@@ -50,7 +59,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data && data?.mounts.length > 0 && (
|
{canCreate && 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>
|
||||||
@@ -63,9 +72,11 @@ 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>
|
||||||
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
{canCreate && (
|
||||||
Add Volume
|
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
||||||
</AddVolumes>
|
Add Volume
|
||||||
|
</AddVolumes>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col pt-2 gap-4">
|
<div className="flex flex-col pt-2 gap-4">
|
||||||
@@ -130,38 +141,42 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-1">
|
<div className="flex flex-row gap-1">
|
||||||
<UpdateVolume
|
{canCreate && (
|
||||||
mountId={mount.mountId}
|
<UpdateVolume
|
||||||
type={mount.type}
|
mountId={mount.mountId}
|
||||||
refetch={refetch}
|
type={mount.type}
|
||||||
serviceType={type}
|
refetch={refetch}
|
||||||
/>
|
serviceType={type}
|
||||||
<DialogAction
|
/>
|
||||||
title="Delete Volume"
|
)}
|
||||||
description="Are you sure you want to delete this volume?"
|
{canDelete && (
|
||||||
type="destructive"
|
<DialogAction
|
||||||
onClick={async () => {
|
title="Delete Volume"
|
||||||
await deleteVolume({
|
description="Are you sure you want to delete this volume?"
|
||||||
mountId: mount.mountId,
|
type="destructive"
|
||||||
})
|
onClick={async () => {
|
||||||
.then(() => {
|
await deleteVolume({
|
||||||
refetch();
|
mountId: mount.mountId,
|
||||||
toast.success("Volume deleted successfully");
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then(() => {
|
||||||
toast.error("Error deleting volume");
|
refetch();
|
||||||
});
|
toast.success("Volume deleted successfully");
|
||||||
}}
|
})
|
||||||
>
|
.catch(() => {
|
||||||
<Button
|
toast.error("Error deleting volume");
|
||||||
variant="ghost"
|
});
|
||||||
size="icon"
|
}}
|
||||||
className="group hover:bg-red-500/10"
|
|
||||||
isLoading={isRemoving}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
</DialogAction>
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10"
|
||||||
|
isLoading={isRemoving}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
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";
|
||||||
@@ -41,7 +41,13 @@ const mySchema = z.discriminatedUnion("type", [
|
|||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
type: z.literal("volume"),
|
type: z.literal("volume"),
|
||||||
volumeName: z.string().min(1, "Volume name required"),
|
volumeName: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Volume name required")
|
||||||
|
.regex(
|
||||||
|
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
|
||||||
|
"Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.merge(mountSchema),
|
.merge(mountSchema),
|
||||||
z
|
z
|
||||||
@@ -61,13 +67,13 @@ interface Props {
|
|||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
serviceType:
|
serviceType:
|
||||||
| "application"
|
| "application"
|
||||||
| "postgres"
|
| "compose"
|
||||||
| "redis"
|
| "libsql"
|
||||||
| "mongo"
|
|
||||||
| "redis"
|
|
||||||
| "mysql"
|
|
||||||
| "mariadb"
|
| "mariadb"
|
||||||
| "compose";
|
| "mongo"
|
||||||
|
| "mysql"
|
||||||
|
| "postgres"
|
||||||
|
| "redis";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UpdateVolume = ({
|
export const UpdateVolume = ({
|
||||||
@@ -87,7 +93,7 @@ export const UpdateVolume = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync, isLoading, error, isError } =
|
const { mutateAsync, isPending, error, isError } =
|
||||||
api.mounts.update.useMutation();
|
api.mounts.update.useMutation();
|
||||||
|
|
||||||
const form = useForm<UpdateMount>({
|
const form = useForm<UpdateMount>({
|
||||||
@@ -181,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={isLoading}
|
isLoading={isPending}
|
||||||
>
|
>
|
||||||
<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>
|
||||||
@@ -247,7 +253,7 @@ export const UpdateVolume = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="content"
|
name="content"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="max-w-full max-w-[45rem]">
|
<FormItem className="w-full max-w-[45rem]">
|
||||||
<FormLabel>Content</FormLabel>
|
<FormLabel>Content</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -304,7 +310,7 @@ PORT=3000
|
|||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoading}
|
isLoading={isPending}
|
||||||
// form="hook-form-update-volume"
|
// form="hook-form-update-volume"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { Cog } from "lucide-react";
|
import { Cog } from "lucide-react";
|
||||||
import { useEffect } 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";
|
||||||
@@ -20,8 +20,39 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
// Railpack versions from https://github.com/railwayapp/railpack/releases
|
||||||
|
export const RAILPACK_VERSIONS = [
|
||||||
|
"0.15.4",
|
||||||
|
"0.15.3",
|
||||||
|
"0.15.2",
|
||||||
|
"0.15.1",
|
||||||
|
"0.15.0",
|
||||||
|
"0.14.0",
|
||||||
|
"0.13.0",
|
||||||
|
"0.12.0",
|
||||||
|
"0.11.0",
|
||||||
|
"0.10.0",
|
||||||
|
"0.9.2",
|
||||||
|
"0.9.1",
|
||||||
|
"0.9.0",
|
||||||
|
"0.8.0",
|
||||||
|
"0.7.0",
|
||||||
|
"0.6.0",
|
||||||
|
"0.5.0",
|
||||||
|
"0.4.0",
|
||||||
|
"0.3.0",
|
||||||
|
"0.2.2",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export enum BuildType {
|
export enum BuildType {
|
||||||
dockerfile = "dockerfile",
|
dockerfile = "dockerfile",
|
||||||
heroku_buildpacks = "heroku_buildpacks",
|
heroku_buildpacks = "heroku_buildpacks",
|
||||||
@@ -43,12 +74,7 @@ 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
|
dockerfile: z.string().nullable().default(""),
|
||||||
.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(""),
|
||||||
}),
|
}),
|
||||||
@@ -65,7 +91,7 @@ const mySchema = z.discriminatedUnion("buildType", [
|
|||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal(BuildType.railpack),
|
buildType: z.literal(BuildType.railpack),
|
||||||
railpackVersion: z.string().nullable().default("0.2.2"),
|
railpackVersion: z.string().nullable().default("0.15.4"),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal(BuildType.static),
|
buildType: z.literal(BuildType.static),
|
||||||
@@ -137,14 +163,14 @@ const resetData = (data: ApplicationData): AddTemplate => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||||
const { mutateAsync, isLoading } =
|
const { mutateAsync, isPending } =
|
||||||
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<AddTemplate>({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
buildType: BuildType.nixpacks,
|
buildType: BuildType.nixpacks,
|
||||||
},
|
},
|
||||||
@@ -152,6 +178,8 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const buildType = form.watch("buildType");
|
const buildType = form.watch("buildType");
|
||||||
|
const railpackVersion = form.watch("railpackVersion");
|
||||||
|
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -163,9 +191,22 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
form.reset(resetData(typedData));
|
form.reset(resetData(typedData));
|
||||||
|
|
||||||
|
// Check if railpack version is manual (not in the predefined list)
|
||||||
|
if (
|
||||||
|
data.railpackVersion &&
|
||||||
|
!RAILPACK_VERSIONS.includes(data.railpackVersion as any)
|
||||||
|
) {
|
||||||
|
setIsManualRailpackVersion(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form]);
|
||||||
|
|
||||||
|
// Hide builder section when Docker provider is selected
|
||||||
|
if (data?.sourceType === "docker") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const onSubmit = async (data: AddTemplate) => {
|
const onSubmit = async (data: AddTemplate) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
applicationId,
|
applicationId,
|
||||||
@@ -186,7 +227,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
data.buildType === BuildType.static ? data.isStaticSpa : null,
|
data.buildType === BuildType.static ? data.isStaticSpa : null,
|
||||||
railpackVersion:
|
railpackVersion:
|
||||||
data.buildType === BuildType.railpack
|
data.buildType === BuildType.railpack
|
||||||
? data.railpackVersion || "0.2.2"
|
? data.railpackVersion || "0.15.4"
|
||||||
: null,
|
: null,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -301,7 +342,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Docker File</FormLabel>
|
<FormLabel>Docker File</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Path of your docker file"
|
placeholder="Path of your docker file (default: Dockerfile)"
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
/>
|
/>
|
||||||
@@ -403,26 +444,91 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{buildType === BuildType.railpack && (
|
{buildType === BuildType.railpack && (
|
||||||
<FormField
|
<>
|
||||||
control={form.control}
|
<FormField
|
||||||
name="railpackVersion"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="railpackVersion"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>Railpack Version</FormLabel>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>Railpack Version</FormLabel>
|
||||||
<Input
|
<FormControl>
|
||||||
placeholder="Railpack Version"
|
{isManualRailpackVersion ? (
|
||||||
{...field}
|
<div className="space-y-2">
|
||||||
value={field.value ?? ""}
|
<Input
|
||||||
/>
|
placeholder="Enter custom version (e.g., 0.15.4)"
|
||||||
</FormControl>
|
{...field}
|
||||||
<FormMessage />
|
value={field.value ?? ""}
|
||||||
</FormItem>
|
/>
|
||||||
)}
|
<Button
|
||||||
/>
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsManualRailpackVersion(false);
|
||||||
|
field.onChange("0.15.4");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use predefined versions
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === "manual") {
|
||||||
|
setIsManualRailpackVersion(true);
|
||||||
|
field.onChange("");
|
||||||
|
} else {
|
||||||
|
field.onChange(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={field.value ?? "0.15.4"}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select Railpack version" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="manual">
|
||||||
|
<span className="font-medium">
|
||||||
|
✏️ Manual (Custom Version)
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
{RAILPACK_VERSIONS.map((version) => (
|
||||||
|
<SelectItem key={version} value={version}>
|
||||||
|
v{version}
|
||||||
|
{version === "0.15.4" && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="ml-2 px-1 text-xs"
|
||||||
|
>
|
||||||
|
Latest
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Select a Railpack version or choose manual to enter a
|
||||||
|
custom version.{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/railwayapp/railpack/releases"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-primary underline underline-offset-4"
|
||||||
|
>
|
||||||
|
View releases
|
||||||
|
</a>
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button isLoading={isLoading} type="submit">
|
<Button isLoading={isPending} type="submit">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Paintbrush } from "lucide-react";
|
import { Ban } 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, isLoading } =
|
const { mutateAsync, isPending } =
|
||||||
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={isLoading}>
|
<Button variant="destructive" className="w-fit" isLoading={isPending}>
|
||||||
Cancel Queues
|
Cancel Queues
|
||||||
<Paintbrush className="size-4" />
|
<Ban className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Scissors } 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 KillBuild = ({ id, type }: Props) => {
|
||||||
|
const { mutateAsync, isPending } =
|
||||||
|
type === "application"
|
||||||
|
? api.application.killBuild.useMutation()
|
||||||
|
: api.compose.killBuild.useMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" className="w-fit" isLoading={isPending}>
|
||||||
|
Kill Build
|
||||||
|
<Scissors className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you sure to kill the build?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will kill the build process
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={async () => {
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
composeId: id || "",
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Build killed successfully");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(err.message);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import copy from "copy-to-clipboard";
|
||||||
|
import { Check, Copy, Loader2 } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -29,9 +31,10 @@ export const ShowDeployment = ({
|
|||||||
const [data, setData] = useState("");
|
const [data, setData] = useState("");
|
||||||
const [showExtraLogs, setShowExtraLogs] = useState(false);
|
const [showExtraLogs, setShowExtraLogs] = useState(false);
|
||||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||||
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const [autoScroll, setAutoScroll] = useState(true);
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
if (autoScroll && scrollRef.current) {
|
if (autoScroll && scrollRef.current) {
|
||||||
@@ -106,6 +109,20 @@ export const ShowDeployment = ({
|
|||||||
}
|
}
|
||||||
}, [filteredLogs, autoScroll]);
|
}, [filteredLogs, autoScroll]);
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
const logContent = filteredLogs
|
||||||
|
.map(({ timestamp, message }: LogLine) =>
|
||||||
|
`${timestamp?.toISOString() || ""} ${message}`.trim(),
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const success = copy(logContent);
|
||||||
|
if (success) {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const optionalErrors = parseLogs(errorMessage || "");
|
const optionalErrors = parseLogs(errorMessage || "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -128,13 +145,27 @@ export const ShowDeployment = ({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Deployment</DialogTitle>
|
<DialogTitle>Deployment</DialogTitle>
|
||||||
<DialogDescription className="flex items-center gap-2">
|
<DialogDescription className="flex items-center gap-2">
|
||||||
<span>
|
<span className="flex items-center gap-2">
|
||||||
See all the details of this deployment |{" "}
|
See all the details of this deployment |{" "}
|
||||||
<Badge variant="blank" className="text-xs">
|
<Badge variant="blank" className="text-xs">
|
||||||
{filteredLogs.length} lines
|
{filteredLogs.length} lines
|
||||||
</Badge>
|
</Badge>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7"
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={filteredLogs.length === 0}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
{serverId && (
|
{serverId && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -163,13 +194,21 @@ export const ShowDeployment = ({
|
|||||||
{" "}
|
{" "}
|
||||||
{filteredLogs.length > 0 ? (
|
{filteredLogs.length > 0 ? (
|
||||||
filteredLogs.map((log: LogLine, index: number) => (
|
filteredLogs.map((log: LogLine, index: number) => (
|
||||||
<TerminalLine key={index} log={log} noTimestamp />
|
<TerminalLine
|
||||||
|
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 key={`extra-${index}`} log={log} noTimestamp />
|
<TerminalLine
|
||||||
|
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,6 +1,17 @@
|
|||||||
import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react";
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Clock,
|
||||||
|
Copy,
|
||||||
|
Loader2,
|
||||||
|
RefreshCcw,
|
||||||
|
RocketIcon,
|
||||||
|
Settings,
|
||||||
|
Trash2,
|
||||||
|
} 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";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
@@ -17,6 +28,8 @@ 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 { RefreshToken } from "./refresh-token";
|
import { RefreshToken } from "./refresh-token";
|
||||||
import { ShowDeployment } from "./show-deployment";
|
import { ShowDeployment } from "./show-deployment";
|
||||||
|
|
||||||
@@ -50,7 +63,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, isLoading: isLoadingDeployments } =
|
const { data: deployments, isPending: isLoadingDeployments } =
|
||||||
api.deployment.allByType.useQuery(
|
api.deployment.allByType.useQuery(
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -64,22 +77,47 @@ export const ShowDeployments = ({
|
|||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
|
||||||
const { mutateAsync: rollback, isLoading: isRollingBack } =
|
const { mutateAsync: rollback, isPending: isRollingBack } =
|
||||||
api.rollback.rollback.useMutation();
|
api.rollback.rollback.useMutation();
|
||||||
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
|
const { mutateAsync: killProcess, isPending: 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,
|
||||||
isLoading: isCancellingApp,
|
isPending: isCancellingApp,
|
||||||
} = api.application.cancelDeployment.useMutation();
|
} = api.application.cancelDeployment.useMutation();
|
||||||
const {
|
const {
|
||||||
mutateAsync: cancelComposeDeployment,
|
mutateAsync: cancelComposeDeployment,
|
||||||
isLoading: isCancellingCompose,
|
isPending: isCancellingCompose,
|
||||||
} = api.compose.cancelDeployment.useMutation();
|
} = api.compose.cancelDeployment.useMutation();
|
||||||
|
|
||||||
const [url, setUrl] = React.useState("");
|
const [url, setUrl] = React.useState("");
|
||||||
|
const [expandedDescriptions, setExpandedDescriptions] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const webhookUrl = useMemo(
|
||||||
|
() =>
|
||||||
|
`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`,
|
||||||
|
[url, refreshToken, type],
|
||||||
|
);
|
||||||
|
|
||||||
|
const MAX_DESCRIPTION_LENGTH = 200;
|
||||||
|
|
||||||
|
const truncateDescription = (description: string): string => {
|
||||||
|
if (description.length <= MAX_DESCRIPTION_LENGTH) {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
const truncated = description.slice(0, MAX_DESCRIPTION_LENGTH);
|
||||||
|
const lastSpace = truncated.lastIndexOf(" ");
|
||||||
|
if (lastSpace > MAX_DESCRIPTION_LENGTH - 20 && lastSpace > 0) {
|
||||||
|
return `${truncated.slice(0, lastSpace)}...`;
|
||||||
|
}
|
||||||
|
return `${truncated}...`;
|
||||||
|
};
|
||||||
|
|
||||||
// Check for stuck deployment (more than 9 minutes) - only for the most recent deployment
|
// Check for stuck deployment (more than 9 minutes) - only for the most recent deployment
|
||||||
const stuckDeployment = useMemo(() => {
|
const stuckDeployment = useMemo(() => {
|
||||||
@@ -117,7 +155,13 @@ export const ShowDeployments = ({
|
|||||||
See the last 10 deployments for this {type}
|
See the last 10 deployments for this {type}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center 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") && (
|
||||||
|
<KillBuild id={id} type={type} />
|
||||||
|
)}
|
||||||
{(type === "application" || type === "compose") && (
|
{(type === "application" || type === "compose") && (
|
||||||
<CancelQueues id={id} type={type} />
|
<CancelQueues id={id} type={type} />
|
||||||
)}
|
)}
|
||||||
@@ -188,11 +232,27 @@ 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">
|
||||||
<span className="break-all text-muted-foreground">
|
<Badge
|
||||||
{`${url}/api/deploy${
|
role="button"
|
||||||
type === "compose" ? "/compose" : ""
|
tabIndex={0}
|
||||||
}/${refreshToken}`}
|
aria-label="Copy webhook URL to clipboard"
|
||||||
</span>
|
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"
|
||||||
|
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} />
|
||||||
)}
|
)}
|
||||||
@@ -217,122 +277,212 @@ export const ShowDeployments = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{deployments?.map((deployment, index) => (
|
{deployments?.map((deployment, index) => {
|
||||||
<div
|
const titleText = deployment?.title?.trim() || "";
|
||||||
key={deployment.deploymentId}
|
const needsTruncation = titleText.length > MAX_DESCRIPTION_LENGTH;
|
||||||
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
const isExpanded = expandedDescriptions.has(
|
||||||
>
|
deployment.deploymentId,
|
||||||
<div className="flex flex-col">
|
);
|
||||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
const canDelete =
|
||||||
{index + 1}. {deployment.status}
|
deployment.status === "done" || deployment.status === "error";
|
||||||
<StatusTooltip
|
|
||||||
status={deployment?.status}
|
return (
|
||||||
className="size-2.5"
|
<div
|
||||||
/>
|
key={deployment.deploymentId}
|
||||||
</span>
|
className="flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
<span className="text-sm text-muted-foreground">
|
>
|
||||||
{deployment.title}
|
<div className="flex flex-1 flex-col min-w-0">
|
||||||
</span>
|
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||||
{deployment.description && (
|
{index + 1}. {deployment.status}
|
||||||
<span className="break-all text-sm text-muted-foreground">
|
<StatusTooltip
|
||||||
{deployment.description}
|
status={deployment?.status}
|
||||||
|
className="size-2.5"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end gap-2">
|
|
||||||
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
|
||||||
<DateTooltip date={deployment.createdAt} />
|
|
||||||
{deployment.startedAt && deployment.finishedAt && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px] gap-1 flex items-center"
|
|
||||||
>
|
|
||||||
<Clock className="size-3" />
|
|
||||||
{formatDuration(
|
|
||||||
Math.floor(
|
|
||||||
(new Date(deployment.finishedAt).getTime() -
|
|
||||||
new Date(deployment.startedAt).getTime()) /
|
|
||||||
1000,
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
{deployment.pid && deployment.status === "running" && (
|
<span className="break-words text-sm text-muted-foreground whitespace-pre-wrap">
|
||||||
<DialogAction
|
{isExpanded || !needsTruncation
|
||||||
title="Kill Process"
|
? titleText
|
||||||
description="Are you sure you want to kill the process?"
|
: truncateDescription(titleText)}
|
||||||
type="default"
|
</span>
|
||||||
onClick={async () => {
|
{needsTruncation && (
|
||||||
await killProcess({
|
<button
|
||||||
deploymentId: deployment.deploymentId,
|
type="button"
|
||||||
})
|
onClick={() => {
|
||||||
.then(() => {
|
const next = new Set(expandedDescriptions);
|
||||||
toast.success("Process killed successfully");
|
if (next.has(deployment.deploymentId)) {
|
||||||
})
|
next.delete(deployment.deploymentId);
|
||||||
.catch(() => {
|
} else {
|
||||||
toast.error("Error killing process");
|
next.add(deployment.deploymentId);
|
||||||
});
|
}
|
||||||
}}
|
setExpandedDescriptions(next);
|
||||||
>
|
}}
|
||||||
<Button
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors w-fit mt-1 cursor-pointer"
|
||||||
variant="destructive"
|
aria-label={
|
||||||
size="sm"
|
isExpanded
|
||||||
isLoading={isKillingProcess}
|
? "Collapse commit message"
|
||||||
|
: "Expand commit message"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Kill Process
|
{isExpanded ? (
|
||||||
</Button>
|
<>
|
||||||
</DialogAction>
|
<ChevronUp className="size-3" />
|
||||||
)}
|
Show less
|
||||||
<Button
|
</>
|
||||||
onClick={() => {
|
) : (
|
||||||
setActiveLog(deployment);
|
<>
|
||||||
}}
|
<ChevronDown className="size-3" />
|
||||||
>
|
Show more
|
||||||
View
|
</>
|
||||||
</Button>
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Hash (from description) - shown in compact form */}
|
||||||
|
{deployment.description?.trim() && (
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
{deployment.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col items-start gap-2 sm:w-auto sm:max-w-[300px] sm:items-end sm:justify-start">
|
||||||
|
<div className="text-sm capitalize text-muted-foreground flex flex-wrap items-center gap-2">
|
||||||
|
<DateTooltip date={deployment.createdAt} />
|
||||||
|
{deployment.startedAt && deployment.finishedAt && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] gap-1 flex items-center"
|
||||||
|
>
|
||||||
|
<Clock className="size-3" />
|
||||||
|
{formatDuration(
|
||||||
|
Math.floor(
|
||||||
|
(new Date(deployment.finishedAt).getTime() -
|
||||||
|
new Date(deployment.startedAt).getTime()) /
|
||||||
|
1000,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{deployment?.rollback &&
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center sm:justify-end">
|
||||||
deployment.status === "done" &&
|
{deployment.pid && deployment.status === "running" && (
|
||||||
type === "application" && (
|
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Rollback to this deployment"
|
title="Kill Process"
|
||||||
description="Are you sure you want to rollback to this deployment?"
|
description="Are you sure you want to kill the process?"
|
||||||
type="default"
|
type="default"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await rollback({
|
await killProcess({
|
||||||
rollbackId: deployment.rollback.rollbackId,
|
deploymentId: deployment.deploymentId,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(
|
toast.success("Process killed successfully");
|
||||||
"Rollback initiated successfully",
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error initiating rollback");
|
toast.error("Error killing process");
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
isLoading={isRollingBack}
|
isLoading={isKillingProcess}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
Kill Process
|
||||||
Rollback
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveLog(deployment);
|
||||||
|
}}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</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.status === "done" &&
|
||||||
|
type === "application" && (
|
||||||
|
<DialogAction
|
||||||
|
title="Rollback to this deployment"
|
||||||
|
description={
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p>
|
||||||
|
Are you sure you want to rollback to this
|
||||||
|
deployment?
|
||||||
|
</p>
|
||||||
|
<AlertBlock type="info" className="text-sm">
|
||||||
|
Please wait a few seconds while the image is
|
||||||
|
pulled from the registry. Your application
|
||||||
|
should be running shortly.
|
||||||
|
</AlertBlock>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
type="default"
|
||||||
|
onClick={async () => {
|
||||||
|
await rollback({
|
||||||
|
rollbackId: deployment.rollback.rollbackId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(
|
||||||
|
"Rollback initiated successfully",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error initiating rollback");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
isLoading={isRollingBack}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
Rollback
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ShowDeployment
|
<ShowDeployment
|
||||||
serverId={serverId}
|
serverId={activeLog?.buildServerId || serverId}
|
||||||
open={Boolean(activeLog && activeLog.logPath !== null)}
|
open={Boolean(activeLog && activeLog.logPath !== null)}
|
||||||
onClose={() => setActiveLog(null)}
|
onClose={() => setActiveLog(null)}
|
||||||
logPath={activeLog?.logPath || ""}
|
logPath={activeLog?.logPath || ""}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { DatabaseZap, Dices, RefreshCw } 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";
|
||||||
@@ -46,7 +46,13 @@ export type CacheType = "fetch" | "cache";
|
|||||||
|
|
||||||
export const domain = z
|
export const domain = z
|
||||||
.object({
|
.object({
|
||||||
host: z.string().min(1, { message: "Add a hostname" }),
|
host: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Add a hostname" })
|
||||||
|
.refine((val) => val === val.trim(), {
|
||||||
|
message: "Domain name cannot have leading or trailing spaces",
|
||||||
|
})
|
||||||
|
.transform((val) => val.trim()),
|
||||||
path: z.string().min(1).optional(),
|
path: z.string().min(1).optional(),
|
||||||
internalPath: z.string().optional(),
|
internalPath: z.string().optional(),
|
||||||
stripPath: z.boolean().optional(),
|
stripPath: z.boolean().optional(),
|
||||||
@@ -153,11 +159,11 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync, isError, error, isLoading } = domainId
|
const { mutateAsync, isError, error, isPending } = domainId
|
||||||
? api.domain.update.useMutation()
|
? api.domain.update.useMutation()
|
||||||
: api.domain.create.useMutation();
|
: api.domain.create.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
|
const { mutateAsync: generateDomain, isPending: isLoadingGenerate } =
|
||||||
api.domain.generateDomain.useMutation();
|
api.domain.generateDomain.useMutation();
|
||||||
|
|
||||||
const { data: canGenerateTraefikMeDomains } =
|
const { data: canGenerateTraefikMeDomains } =
|
||||||
@@ -202,6 +208,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
const certificateType = form.watch("certificateType");
|
const certificateType = form.watch("certificateType");
|
||||||
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 isTraefikMeDomain = host?.includes("traefik.me") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -232,7 +240,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
domainType: type,
|
domainType: type,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, data, isLoading, domainId]);
|
}, [form, data, isPending, domainId]);
|
||||||
|
|
||||||
// Separate effect for handling custom cert resolver validation
|
// Separate effect for handling custom cert resolver validation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -299,6 +307,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
|
|
||||||
|
{type === "compose" && (
|
||||||
|
<AlertBlock type="info" className="mb-4">
|
||||||
|
Whenever you make changes to domains, remember to redeploy your
|
||||||
|
compose to apply the changes.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
id="hook-form"
|
id="hook-form"
|
||||||
@@ -489,6 +504,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
to make your traefik.me domain work.
|
to make your traefik.me domain work.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
|
{isTraefikMeDomain && (
|
||||||
|
<AlertBlock type="info">
|
||||||
|
<strong>Note:</strong> traefik.me is a public HTTP
|
||||||
|
service and does not support SSL/HTTPS. HTTPS and
|
||||||
|
certificate options will not have any effect.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
<FormLabel>Host</FormLabel>
|
<FormLabel>Host</FormLabel>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -708,7 +730,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button isLoading={isLoading} form="hook-form" type="submit">
|
<Button isLoading={isPending} form="hook-form" type="submit">
|
||||||
{dictionary.submit}
|
{dictionary.submit}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ 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(
|
||||||
@@ -97,7 +100,7 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
|
|
||||||
const { mutateAsync: validateDomain } =
|
const { mutateAsync: validateDomain } =
|
||||||
api.domain.validateDomain.useMutation();
|
api.domain.validateDomain.useMutation();
|
||||||
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
|
const { mutateAsync: deleteDomain, isPending: isRemoving } =
|
||||||
api.domain.delete.useMutation();
|
api.domain.delete.useMutation();
|
||||||
|
|
||||||
const handleValidateDomain = async (host: string) => {
|
const handleValidateDomain = async (host: string) => {
|
||||||
@@ -149,7 +152,7 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row gap-4 flex-wrap">
|
<div className="flex flex-row gap-4 flex-wrap">
|
||||||
{data && data?.length > 0 && (
|
{canCreateDomain && data && data?.length > 0 && (
|
||||||
<AddDomain id={id} type={type}>
|
<AddDomain id={id} type={type}>
|
||||||
<Button>
|
<Button>
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
<GlobeIcon className="size-4" /> Add Domain
|
||||||
@@ -173,13 +176,15 @@ 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>
|
||||||
<div className="flex flex-row gap-4 flex-wrap">
|
{canCreateDomain && (
|
||||||
<AddDomain id={id} type={type}>
|
<div className="flex flex-row gap-4 flex-wrap">
|
||||||
<Button>
|
<AddDomain id={id} type={type}>
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
<Button>
|
||||||
</Button>
|
<GlobeIcon className="size-4" /> Add Domain
|
||||||
</AddDomain>
|
</Button>
|
||||||
</div>
|
</AddDomain>
|
||||||
|
</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] ">
|
||||||
@@ -214,47 +219,51 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<AddDomain
|
{canCreateDomain && (
|
||||||
id={id}
|
<AddDomain
|
||||||
type={type}
|
id={id}
|
||||||
domainId={item.domainId}
|
type={type}
|
||||||
>
|
domainId={item.domainId}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-blue-500/10"
|
|
||||||
>
|
>
|
||||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
</AddDomain>
|
size="icon"
|
||||||
<DialogAction
|
className="group hover:bg-blue-500/10"
|
||||||
title="Delete Domain"
|
>
|
||||||
description="Are you sure you want to delete this domain?"
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
type="destructive"
|
</Button>
|
||||||
onClick={async () => {
|
</AddDomain>
|
||||||
await deleteDomain({
|
)}
|
||||||
domainId: item.domainId,
|
{canDeleteDomain && (
|
||||||
})
|
<DialogAction
|
||||||
.then((_data) => {
|
title="Delete Domain"
|
||||||
refetch();
|
description="Are you sure you want to delete this domain?"
|
||||||
toast.success(
|
type="destructive"
|
||||||
"Domain deleted successfully",
|
onClick={async () => {
|
||||||
);
|
await deleteDomain({
|
||||||
|
domainId: item.domainId,
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then((_data) => {
|
||||||
toast.error("Error deleting domain");
|
refetch();
|
||||||
});
|
toast.success(
|
||||||
}}
|
"Domain deleted successfully",
|
||||||
>
|
);
|
||||||
<Button
|
})
|
||||||
variant="ghost"
|
.catch(() => {
|
||||||
size="icon"
|
toast.error("Error deleting domain");
|
||||||
className="group hover:bg-red-500/10"
|
});
|
||||||
isLoading={isRemoving}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
</DialogAction>
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10"
|
||||||
|
isLoading={isRemoving}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full break-all">
|
<div className="w-full break-all">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
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,16 +36,19 @@ 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 = {
|
||||||
postgres: () =>
|
compose: () =>
|
||||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }),
|
||||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
|
||||||
mariadb: () =>
|
mariadb: () =>
|
||||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
compose: () =>
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
};
|
};
|
||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
@@ -53,14 +56,15 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
const [isEnvVisible, setIsEnvVisible] = useState(true);
|
||||||
|
|
||||||
const mutationMap = {
|
const mutationMap = {
|
||||||
postgres: () => api.postgres.update.useMutation(),
|
compose: () => api.compose.update.useMutation(),
|
||||||
redis: () => api.redis.update.useMutation(),
|
libsql: () => api.libsql.update.useMutation(),
|
||||||
mysql: () => api.mysql.update.useMutation(),
|
|
||||||
mariadb: () => api.mariadb.update.useMutation(),
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
compose: () => api.compose.update.useMutation(),
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
|
redis: () => api.redis.update.useMutation(),
|
||||||
};
|
};
|
||||||
const { mutateAsync, isLoading } = mutationMap[type]
|
const { mutateAsync, isPending } = mutationMap[type]
|
||||||
? mutationMap[type]()
|
? mutationMap[type]()
|
||||||
: api.mongo.update.useMutation();
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
@@ -85,12 +89,13 @@ 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 () => {
|
||||||
@@ -108,6 +113,21 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||||
|
e.preventDefault();
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [form, onSubmit, isPending]);
|
||||||
|
|
||||||
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">
|
||||||
@@ -170,25 +190,27 @@ PORT=3000
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-row justify-end gap-2">
|
{canWrite && (
|
||||||
{hasChanges && (
|
<div className="flex flex-row justify-end gap-2">
|
||||||
|
{hasChanges && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
isLoading={isPending}
|
||||||
variant="outline"
|
className="w-fit"
|
||||||
onClick={handleCancel}
|
type="submit"
|
||||||
|
disabled={!hasChanges}
|
||||||
>
|
>
|
||||||
Cancel
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
<Button
|
)}
|
||||||
isLoading={isLoading}
|
|
||||||
className="w-fit"
|
|
||||||
type="submit"
|
|
||||||
disabled={!hasChanges}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
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";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Form } from "@/components/ui/form";
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from "@/components/ui/form";
|
||||||
import { Secrets } from "@/components/ui/secrets";
|
import { Secrets } from "@/components/ui/secrets";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const addEnvironmentSchema = z.object({
|
const addEnvironmentSchema = z.object({
|
||||||
env: z.string(),
|
env: z.string(),
|
||||||
buildArgs: z.string(),
|
buildArgs: z.string(),
|
||||||
|
buildSecrets: z.string(),
|
||||||
|
createEnvFile: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
||||||
@@ -21,7 +31,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowEnvironment = ({ applicationId }: Props) => {
|
export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||||
const { mutateAsync, isLoading } =
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
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(
|
||||||
@@ -37,6 +49,8 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
env: "",
|
env: "",
|
||||||
buildArgs: "",
|
buildArgs: "",
|
||||||
|
buildSecrets: "",
|
||||||
|
createEnvFile: true,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(addEnvironmentSchema),
|
resolver: zodResolver(addEnvironmentSchema),
|
||||||
});
|
});
|
||||||
@@ -44,15 +58,21 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
// Watch form values
|
// Watch form values
|
||||||
const currentEnv = form.watch("env");
|
const currentEnv = form.watch("env");
|
||||||
const currentBuildArgs = form.watch("buildArgs");
|
const currentBuildArgs = form.watch("buildArgs");
|
||||||
|
const currentBuildSecrets = form.watch("buildSecrets");
|
||||||
|
const currentCreateEnvFile = form.watch("createEnvFile");
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
currentEnv !== (data?.env || "") ||
|
currentEnv !== (data?.env || "") ||
|
||||||
currentBuildArgs !== (data?.buildArgs || "");
|
currentBuildArgs !== (data?.buildArgs || "") ||
|
||||||
|
currentBuildSecrets !== (data?.buildSecrets || "") ||
|
||||||
|
currentCreateEnvFile !== (data?.createEnvFile ?? true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
env: data.env || "",
|
env: data.env || "",
|
||||||
buildArgs: data.buildArgs || "",
|
buildArgs: data.buildArgs || "",
|
||||||
|
buildSecrets: data.buildSecrets || "",
|
||||||
|
createEnvFile: data.createEnvFile ?? true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form]);
|
||||||
@@ -61,6 +81,8 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
mutateAsync({
|
mutateAsync({
|
||||||
env: formData.env,
|
env: formData.env,
|
||||||
buildArgs: formData.buildArgs,
|
buildArgs: formData.buildArgs,
|
||||||
|
buildSecrets: formData.buildSecrets,
|
||||||
|
createEnvFile: formData.createEnvFile,
|
||||||
applicationId,
|
applicationId,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -76,9 +98,26 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
form.reset({
|
form.reset({
|
||||||
env: data?.env || "",
|
env: data?.env || "",
|
||||||
buildArgs: data?.buildArgs || "",
|
buildArgs: data?.buildArgs || "",
|
||||||
|
buildSecrets: data?.buildSecrets || "",
|
||||||
|
createEnvFile: data?.createEnvFile ?? true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||||
|
e.preventDefault();
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [form, onSubmit, isPending]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background px-6 pb-6">
|
<Card className="bg-background px-6 pb-6">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -104,13 +143,14 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
{data?.buildType === "dockerfile" && (
|
{data?.buildType === "dockerfile" && (
|
||||||
<Secrets
|
<Secrets
|
||||||
name="buildArgs"
|
name="buildArgs"
|
||||||
title="Build-time Variables"
|
title="Build-time Arguments"
|
||||||
description={
|
description={
|
||||||
<span>
|
<span>
|
||||||
Available only at build-time. See documentation
|
Arguments are available only at build-time. See
|
||||||
|
documentation
|
||||||
<a
|
<a
|
||||||
className="text-primary"
|
className="text-primary"
|
||||||
href="https://docs.docker.com/build/guide/build-args/"
|
href="https://docs.docker.com/build/building/variables/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
@@ -122,21 +162,71 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
placeholder="NPM_TOKEN=xyz"
|
placeholder="NPM_TOKEN=xyz"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-row justify-end gap-2">
|
{data?.buildType === "dockerfile" && (
|
||||||
{hasChanges && (
|
<Secrets
|
||||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
name="buildSecrets"
|
||||||
Cancel
|
title="Build-time Secrets"
|
||||||
|
description={
|
||||||
|
<span>
|
||||||
|
Secrets are specially designed for sensitive information and
|
||||||
|
are only available at build-time. See documentation
|
||||||
|
<a
|
||||||
|
className="text-primary"
|
||||||
|
href="https://docs.docker.com/build/building/secrets/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
placeholder="NPM_TOKEN=xyz"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{data?.buildType === "dockerfile" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="createEnvFile"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-sm">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Create Environment File</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
When enabled, an .env file will be created in the same
|
||||||
|
directory as your Dockerfile during the build process.
|
||||||
|
Disable this if you don't want to generate an environment
|
||||||
|
file.
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
disabled={!canWrite}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canWrite && (
|
||||||
|
<div className="flex flex-row justify-end gap-2">
|
||||||
|
{hasChanges && (
|
||||||
|
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||||
|
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,5 +1,5 @@
|
|||||||
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";
|
||||||
@@ -54,6 +54,7 @@ const BitbucketProviderSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
repo: z.string().min(1, "Repo is required"),
|
repo: z.string().min(1, "Repo is required"),
|
||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
|
slug: z.string().optional(),
|
||||||
})
|
})
|
||||||
.required(),
|
.required(),
|
||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z.string().min(1, "Branch is required"),
|
||||||
@@ -73,15 +74,16 @@ 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, isLoading: isSavingBitbucketProvider } =
|
const { mutateAsync, isPending: isSavingBitbucketProvider } =
|
||||||
api.application.saveBitbucketProvider.useMutation();
|
api.application.saveBitbucketProvider.useMutation();
|
||||||
|
|
||||||
const form = useForm<BitbucketProvider>({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
buildPath: "/",
|
buildPath: "/",
|
||||||
repository: {
|
repository: {
|
||||||
owner: "",
|
owner: "",
|
||||||
repo: "",
|
repo: "",
|
||||||
|
slug: "",
|
||||||
},
|
},
|
||||||
bitbucketId: "",
|
bitbucketId: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
@@ -114,11 +116,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
} = api.bitbucket.getBitbucketBranches.useQuery(
|
} = api.bitbucket.getBitbucketBranches.useQuery(
|
||||||
{
|
{
|
||||||
owner: repository?.owner,
|
owner: repository?.owner,
|
||||||
repo: repository?.repo,
|
repo: repository?.slug || repository?.repo || "",
|
||||||
bitbucketId,
|
bitbucketId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!repository?.owner && !!repository?.repo && !!bitbucketId,
|
enabled:
|
||||||
|
!!repository?.owner &&
|
||||||
|
!!(repository?.slug || repository?.repo) &&
|
||||||
|
!!bitbucketId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -129,6 +134,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
repository: {
|
repository: {
|
||||||
repo: data.bitbucketRepository || "",
|
repo: data.bitbucketRepository || "",
|
||||||
owner: data.bitbucketOwner || "",
|
owner: data.bitbucketOwner || "",
|
||||||
|
slug: data.bitbucketRepositorySlug || "",
|
||||||
},
|
},
|
||||||
buildPath: data.bitbucketBuildPath || "/",
|
buildPath: data.bitbucketBuildPath || "/",
|
||||||
bitbucketId: data.bitbucketId || "",
|
bitbucketId: data.bitbucketId || "",
|
||||||
@@ -142,6 +148,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
bitbucketBranch: data.branch,
|
bitbucketBranch: data.branch,
|
||||||
bitbucketRepository: data.repository.repo,
|
bitbucketRepository: data.repository.repo,
|
||||||
|
bitbucketRepositorySlug: data.repository.slug || data.repository.repo,
|
||||||
bitbucketOwner: data.repository.owner,
|
bitbucketOwner: data.repository.owner,
|
||||||
bitbucketBuildPath: data.buildPath,
|
bitbucketBuildPath: data.buildPath,
|
||||||
bitbucketId: data.bitbucketId,
|
bitbucketId: data.bitbucketId,
|
||||||
@@ -150,7 +157,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
enableSubmodules: data.enableSubmodules || false,
|
enableSubmodules: data.enableSubmodules || false,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provided Saved");
|
toast.success("Service Provider Saved");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -181,6 +188,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
repo: "",
|
repo: "",
|
||||||
|
slug: "",
|
||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
@@ -217,7 +225,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Repository</FormLabel>
|
<FormLabel>Repository</FormLabel>
|
||||||
{field.value.owner && field.value.repo && (
|
{field.value.owner && field.value.repo && (
|
||||||
<Link
|
<Link
|
||||||
href={`https://bitbucket.org/${field.value.owner}/${field.value.repo}`}
|
href={`https://bitbucket.org/${field.value.owner}/${field.value.slug || field.value.repo}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
@@ -237,13 +245,13 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isLoadingRepositories
|
{!field.value.owner
|
||||||
? "Loading...."
|
? "Select repository"
|
||||||
: field.value.owner
|
: isLoadingRepositories
|
||||||
? repositories?.find(
|
? "Loading...."
|
||||||
|
: (repositories?.find(
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name
|
)?.name ?? "Select repository")}
|
||||||
: "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,11 +263,15 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{isLoadingRepositories && (
|
{!bitbucketId ? (
|
||||||
|
<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>
|
||||||
@@ -271,6 +283,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: repo.owner.username as string,
|
owner: repo.owner.username as string,
|
||||||
repo: repo.name,
|
repo: repo.name,
|
||||||
|
slug: repo.slug,
|
||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
@@ -320,7 +333,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === "loading" && fetchStatus === "fetching"
|
{status === "pending" && fetchStatus === "fetching"
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: field.value
|
: field.value
|
||||||
? branches?.find(
|
? branches?.find(
|
||||||
@@ -337,7 +350,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search branch..."
|
placeholder="Search branch..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{status === "loading" && fetchStatus === "fetching" && (
|
{status === "pending" && 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>
|
||||||
@@ -403,10 +416,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger asChild>
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||||
?
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
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 { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
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, isLoading } =
|
const { mutateAsync, isPending } =
|
||||||
api.application.dropDeployment.useMutation();
|
api.application.dropDeployment.useMutation();
|
||||||
|
|
||||||
const form = useForm<UploadFile>({
|
const form = useForm({
|
||||||
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={isLoading}
|
isLoading={isPending}
|
||||||
disabled={!zip || isLoading}
|
disabled={!zip || isPending}
|
||||||
>
|
>
|
||||||
Deploy{" "}
|
Deploy{" "}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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";
|
||||||
@@ -58,10 +58,10 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { mutateAsync, isLoading } =
|
const { mutateAsync, isPending } =
|
||||||
api.application.saveGitProvider.useMutation();
|
api.application.saveGitProvider.useMutation();
|
||||||
|
|
||||||
const form = useForm<GitProvider>({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
branch: "",
|
branch: "",
|
||||||
buildPath: "/",
|
buildPath: "/",
|
||||||
@@ -228,10 +228,8 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger asChild>
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||||
?
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-[300px]">
|
<TooltipContent className="max-w-[300px]">
|
||||||
<p>
|
<p>
|
||||||
@@ -317,7 +315,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={isLoading}>
|
<Button type="submit" className="w-fit" isLoading={isPending}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
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";
|
||||||
@@ -88,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, isLoading: isSavingGiteaProvider } =
|
const { mutateAsync, isPending: isSavingGiteaProvider } =
|
||||||
api.application.saveGiteaProvider.useMutation();
|
api.application.saveGiteaProvider.useMutation();
|
||||||
|
|
||||||
const form = useForm<GiteaProvider>({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
buildPath: "/",
|
buildPath: "/",
|
||||||
repository: {
|
repository: {
|
||||||
@@ -258,14 +258,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isLoadingRepositories
|
{!field.value.owner
|
||||||
? "Loading...."
|
? "Select repository"
|
||||||
: field.value.owner
|
: isLoadingRepositories
|
||||||
? repositories?.find(
|
? "Loading...."
|
||||||
|
: (repositories?.find(
|
||||||
(repo: GiteaRepository) =>
|
(repo: GiteaRepository) =>
|
||||||
repo.name === field.value.repo,
|
repo.name === field.value.repo,
|
||||||
)?.name
|
)?.name ?? "Select repository")}
|
||||||
: "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>
|
||||||
@@ -277,11 +277,15 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{isLoadingRepositories && (
|
{!giteaId ? (
|
||||||
|
<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>
|
||||||
@@ -349,7 +353,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === "loading" && fetchStatus === "fetching"
|
{status === "pending" && fetchStatus === "fetching"
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: field.value
|
: field.value
|
||||||
? branches?.find(
|
? branches?.find(
|
||||||
@@ -367,7 +371,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search branch..."
|
placeholder="Search branch..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{status === "loading" && fetchStatus === "fetching" && (
|
{status === "pending" && 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>
|
||||||
@@ -459,7 +463,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);
|
||||||
}}
|
}}
|
||||||
@@ -477,7 +481,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 = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -494,7 +498,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,4 +1,4 @@
|
|||||||
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";
|
||||||
@@ -72,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, isLoading: isSavingGithubProvider } =
|
const { mutateAsync, isPending: isSavingGithubProvider } =
|
||||||
api.application.saveGithubProvider.useMutation();
|
api.application.saveGithubProvider.useMutation();
|
||||||
|
|
||||||
const form = useForm<GithubProvider>({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
buildPath: "/",
|
buildPath: "/",
|
||||||
repository: {
|
repository: {
|
||||||
@@ -94,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, isLoading: isLoadingRepositories } =
|
const { data: repositories, isPending: isLoadingRepositories } =
|
||||||
api.github.getGithubRepositories.useQuery(
|
api.github.getGithubRepositories.useQuery(
|
||||||
{
|
{
|
||||||
githubId,
|
githubId,
|
||||||
@@ -149,7 +149,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provided Saved");
|
toast.success("Service Provider Saved");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -233,13 +233,13 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isLoadingRepositories
|
{!field.value.owner
|
||||||
? "Loading...."
|
? "Select repository"
|
||||||
: field.value.owner
|
: isLoadingRepositories
|
||||||
? repositories?.find(
|
? "Loading...."
|
||||||
|
: (repositories?.find(
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name
|
)?.name ?? "Select repository")}
|
||||||
: "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>
|
||||||
@@ -251,11 +251,15 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{isLoadingRepositories && (
|
{!githubId ? (
|
||||||
|
<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>
|
||||||
@@ -316,7 +320,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === "loading" && fetchStatus === "fetching"
|
{status === "pending" && fetchStatus === "fetching"
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: field.value
|
: field.value
|
||||||
? branches?.find(
|
? branches?.find(
|
||||||
@@ -333,7 +337,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search branch..."
|
placeholder="Search branch..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{status === "loading" && fetchStatus === "fetching" && (
|
{status === "pending" && 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>
|
||||||
@@ -455,7 +459,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={index}
|
key={`${path}-${index}`}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
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";
|
||||||
@@ -74,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, isLoading: isSavingGitlabProvider } =
|
const { mutateAsync, isPending: isSavingGitlabProvider } =
|
||||||
api.application.saveGitlabProvider.useMutation();
|
api.application.saveGitlabProvider.useMutation();
|
||||||
|
|
||||||
const form = useForm<GitlabProvider>({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
buildPath: "/",
|
buildPath: "/",
|
||||||
repository: {
|
repository: {
|
||||||
@@ -167,7 +167,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provided Saved");
|
toast.success("Service Provider Saved");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -232,9 +232,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<FormItem className="md:col-span-2 flex flex-col">
|
<FormItem className="md:col-span-2 flex flex-col">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<FormLabel>Repository</FormLabel>
|
<FormLabel>Repository</FormLabel>
|
||||||
{field.value.owner && field.value.repo && (
|
{field.value.gitlabPathNamespace && (
|
||||||
<Link
|
<Link
|
||||||
href={`${gitlabUrl}/${field.value.owner}/${field.value.repo}`}
|
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
@@ -254,13 +254,13 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isLoadingRepositories
|
{!field.value.owner
|
||||||
? "Loading...."
|
? "Select repository"
|
||||||
: field.value.owner
|
: isLoadingRepositories
|
||||||
? repositories?.find(
|
? "Loading...."
|
||||||
|
: (repositories?.find(
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name
|
)?.name ?? "Select repository")}
|
||||||
: "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>
|
||||||
@@ -272,11 +272,15 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search repository..."
|
placeholder="Search repository..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{isLoadingRepositories && (
|
{!gitlabId ? (
|
||||||
|
<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>
|
||||||
@@ -347,7 +351,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === "loading" && fetchStatus === "fetching"
|
{status === "pending" && fetchStatus === "fetching"
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: field.value
|
: field.value
|
||||||
? branches?.find(
|
? branches?.find(
|
||||||
@@ -364,7 +368,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search branch..."
|
placeholder="Search branch..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{status === "loading" && fetchStatus === "fetching" && (
|
{status === "pending" && 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>
|
||||||
@@ -444,7 +448,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={index}
|
key={`${path}-${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, isLoading: isLoadingGithub } =
|
const { data: githubProviders, isPending: isLoadingGithub } =
|
||||||
api.github.githubProviders.useQuery();
|
api.github.githubProviders.useQuery();
|
||||||
const { data: gitlabProviders, isLoading: isLoadingGitlab } =
|
const { data: gitlabProviders, isPending: isLoadingGitlab } =
|
||||||
api.gitlab.gitlabProviders.useQuery();
|
api.gitlab.gitlabProviders.useQuery();
|
||||||
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
|
const { data: bitbucketProviders, isPending: isLoadingBitbucket } =
|
||||||
api.bitbucket.bitbucketProviders.useQuery();
|
api.bitbucket.bitbucketProviders.useQuery();
|
||||||
const { data: giteaProviders, isLoading: isLoadingGitea } =
|
const { data: giteaProviders, isPending: 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,6 +30,9 @@ 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,
|
||||||
@@ -37,14 +40,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, isLoading: isStarting } =
|
const { mutateAsync: start, isPending: isStarting } =
|
||||||
api.application.start.useMutation();
|
api.application.start.useMutation();
|
||||||
const { mutateAsync: stop, isLoading: isStopping } =
|
const { mutateAsync: stop, isPending: 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, isLoading: isReloading } =
|
const { mutateAsync: reload, isPending: isReloading } =
|
||||||
api.application.reload.useMutation();
|
api.application.reload.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: redeploy } = api.application.redeploy.useMutation();
|
const { mutateAsync: redeploy } = api.application.redeploy.useMutation();
|
||||||
@@ -57,128 +60,135 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||||
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
||||||
<DialogAction
|
{canDeploy && (
|
||||||
title="Deploy Application"
|
<DialogAction
|
||||||
description="Are you sure you want to deploy this application?"
|
title="Deploy Application"
|
||||||
type="default"
|
description="Are you sure you want to deploy this application?"
|
||||||
onClick={async () => {
|
type="default"
|
||||||
await deploy({
|
onClick={async () => {
|
||||||
applicationId: applicationId,
|
await deploy({
|
||||||
})
|
applicationId: applicationId,
|
||||||
.then(() => {
|
|
||||||
toast.success("Application deployed successfully");
|
|
||||||
refetch();
|
|
||||||
router.push(
|
|
||||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then(() => {
|
||||||
toast.error("Error deploying application");
|
toast.success("Application deployed successfully");
|
||||||
});
|
refetch();
|
||||||
}}
|
router.push(
|
||||||
>
|
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
|
||||||
<Button
|
);
|
||||||
variant="default"
|
})
|
||||||
isLoading={data?.applicationStatus === "running"}
|
.catch(() => {
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
toast.error("Error deploying application");
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Button
|
||||||
<TooltipTrigger asChild>
|
variant="default"
|
||||||
<div className="flex items-center">
|
isLoading={data?.applicationStatus === "running"}
|
||||||
<Rocket className="size-4 mr-1" />
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
Deploy
|
>
|
||||||
</div>
|
<Tooltip>
|
||||||
</TooltipTrigger>
|
<TooltipTrigger asChild>
|
||||||
<TooltipPrimitive.Portal>
|
<div className="flex items-center">
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
<Rocket className="size-4 mr-1" />
|
||||||
<p>
|
Deploy
|
||||||
Downloads the source code and performs a complete build
|
</div>
|
||||||
</p>
|
</TooltipTrigger>
|
||||||
</TooltipContent>
|
<TooltipPrimitive.Portal>
|
||||||
</TooltipPrimitive.Portal>
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
</Tooltip>
|
<p>
|
||||||
</Button>
|
Downloads the source code and performs a complete
|
||||||
</DialogAction>
|
build
|
||||||
<DialogAction
|
</p>
|
||||||
title="Reload Application"
|
</TooltipContent>
|
||||||
description="Are you sure you want to reload this application?"
|
</TooltipPrimitive.Portal>
|
||||||
type="default"
|
</Tooltip>
|
||||||
onClick={async () => {
|
</Button>
|
||||||
await reload({
|
</DialogAction>
|
||||||
applicationId: applicationId,
|
)}
|
||||||
appName: data?.appName || "",
|
{canDeploy && (
|
||||||
})
|
<DialogAction
|
||||||
.then(() => {
|
title="Reload Application"
|
||||||
toast.success("Application reloaded successfully");
|
description="Are you sure you want to reload this application?"
|
||||||
refetch();
|
type="default"
|
||||||
|
onClick={async () => {
|
||||||
|
await reload({
|
||||||
|
applicationId: applicationId,
|
||||||
|
appName: data?.appName || "",
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then(() => {
|
||||||
toast.error("Error reloading application");
|
toast.success("Application reloaded successfully");
|
||||||
});
|
refetch();
|
||||||
}}
|
})
|
||||||
>
|
.catch(() => {
|
||||||
<Button
|
toast.error("Error reloading application");
|
||||||
variant="secondary"
|
});
|
||||||
isLoading={isReloading}
|
}}
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Button
|
||||||
<TooltipTrigger asChild>
|
variant="secondary"
|
||||||
<div className="flex items-center">
|
isLoading={isReloading}
|
||||||
<RefreshCcw className="size-4 mr-1" />
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
Reload
|
>
|
||||||
</div>
|
<Tooltip>
|
||||||
</TooltipTrigger>
|
<TooltipTrigger asChild>
|
||||||
<TooltipPrimitive.Portal>
|
<div className="flex items-center">
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
<RefreshCcw className="size-4 mr-1" />
|
||||||
<p>Reload the application without rebuilding it</p>
|
Reload
|
||||||
</TooltipContent>
|
</div>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipPrimitive.Portal>
|
||||||
</Button>
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
</DialogAction>
|
<p>Reload the application without rebuilding it</p>
|
||||||
<DialogAction
|
</TooltipContent>
|
||||||
title="Rebuild Application"
|
</TooltipPrimitive.Portal>
|
||||||
description="Are you sure you want to rebuild this application?"
|
</Tooltip>
|
||||||
type="default"
|
</Button>
|
||||||
onClick={async () => {
|
</DialogAction>
|
||||||
await redeploy({
|
)}
|
||||||
applicationId: applicationId,
|
{canDeploy && (
|
||||||
})
|
<DialogAction
|
||||||
.then(() => {
|
title="Rebuild Application"
|
||||||
toast.success("Application rebuilt successfully");
|
description="Are you sure you want to rebuild this application?"
|
||||||
refetch();
|
type="default"
|
||||||
|
onClick={async () => {
|
||||||
|
await redeploy({
|
||||||
|
applicationId: applicationId,
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then(() => {
|
||||||
toast.error("Error rebuilding application");
|
toast.success("Application rebuilt successfully");
|
||||||
});
|
refetch();
|
||||||
}}
|
})
|
||||||
>
|
.catch(() => {
|
||||||
<Button
|
toast.error("Error rebuilding application");
|
||||||
variant="secondary"
|
});
|
||||||
isLoading={data?.applicationStatus === "running"}
|
}}
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Button
|
||||||
<TooltipTrigger asChild>
|
variant="secondary"
|
||||||
<div className="flex items-center">
|
isLoading={data?.applicationStatus === "running"}
|
||||||
<Hammer className="size-4 mr-1" />
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
Rebuild
|
>
|
||||||
</div>
|
<Tooltip>
|
||||||
</TooltipTrigger>
|
<TooltipTrigger asChild>
|
||||||
<TooltipPrimitive.Portal>
|
<div className="flex items-center">
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
<Hammer className="size-4 mr-1" />
|
||||||
<p>
|
Rebuild
|
||||||
Only rebuilds the application without downloading new
|
</div>
|
||||||
code
|
</TooltipTrigger>
|
||||||
</p>
|
<TooltipPrimitive.Portal>
|
||||||
</TooltipContent>
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
</TooltipPrimitive.Portal>
|
<p>
|
||||||
</Tooltip>
|
Only rebuilds the application without downloading new
|
||||||
</Button>
|
code
|
||||||
</DialogAction>
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
</Tooltip>
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
|
|
||||||
{data?.applicationStatus === "idle" ? (
|
{canDeploy && 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?"
|
||||||
@@ -219,7 +229,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?"
|
||||||
@@ -256,7 +266,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
)}
|
) : null}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<DockerTerminalModal
|
<DockerTerminalModal
|
||||||
appName={data?.appName || ""}
|
appName={data?.appName || ""}
|
||||||
@@ -270,49 +280,53 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
Open Terminal
|
Open Terminal
|
||||||
</Button>
|
</Button>
|
||||||
</DockerTerminalModal>
|
</DockerTerminalModal>
|
||||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
{canUpdateService && (
|
||||||
<span className="text-sm font-medium">Autodeploy</span>
|
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||||
<Switch
|
<span className="text-sm font-medium">Autodeploy</span>
|
||||||
aria-label="Toggle autodeploy"
|
<Switch
|
||||||
checked={data?.autoDeploy || false}
|
aria-label="Toggle autodeploy"
|
||||||
onCheckedChange={async (enabled) => {
|
checked={data?.autoDeploy || false}
|
||||||
await update({
|
onCheckedChange={async (enabled) => {
|
||||||
applicationId,
|
await update({
|
||||||
autoDeploy: enabled,
|
applicationId,
|
||||||
})
|
autoDeploy: enabled,
|
||||||
.then(async () => {
|
|
||||||
toast.success("Auto Deploy Updated");
|
|
||||||
await refetch();
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then(async () => {
|
||||||
toast.error("Error updating Auto Deploy");
|
toast.success("Auto Deploy Updated");
|
||||||
});
|
await refetch();
|
||||||
}}
|
})
|
||||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
.catch(() => {
|
||||||
/>
|
toast.error("Error updating Auto Deploy");
|
||||||
</div>
|
});
|
||||||
|
}}
|
||||||
|
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
{canUpdateService && (
|
||||||
<span className="text-sm font-medium">Clean Cache</span>
|
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||||
<Switch
|
<span className="text-sm font-medium">Clean Cache</span>
|
||||||
aria-label="Toggle clean cache"
|
<Switch
|
||||||
checked={data?.cleanCache || false}
|
aria-label="Toggle clean cache"
|
||||||
onCheckedChange={async (enabled) => {
|
checked={data?.cleanCache || false}
|
||||||
await update({
|
onCheckedChange={async (enabled) => {
|
||||||
applicationId,
|
await update({
|
||||||
cleanCache: enabled,
|
applicationId,
|
||||||
})
|
cleanCache: enabled,
|
||||||
.then(async () => {
|
|
||||||
toast.success("Clean Cache Updated");
|
|
||||||
await refetch();
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then(async () => {
|
||||||
toast.error("Error updating Clean Cache");
|
toast.success("Clean Cache Updated");
|
||||||
});
|
await refetch();
|
||||||
}}
|
})
|
||||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
.catch(() => {
|
||||||
/>
|
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} />
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user