diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 000000000..4ffcfaed7 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -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 \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..eafddd06d --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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"] +} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d45c3dac0..e210811b0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,7 +8,7 @@ Before submitting this PR, please make sure that: - [ ] 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 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) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3ed957b72..321fb2029 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,6 +13,17 @@ jobs: - name: Checkout repository 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 uses: docker/login-action@v2 with: @@ -25,8 +36,7 @@ jobs: context: . file: ./Dockerfile.cloud push: true - tags: | - siumauricio/cloud:${{ github.ref_name == 'main' && 'latest' || 'canary' }} + tags: ${{ steps.meta-cloud.outputs.tags }} platforms: linux/amd64 build-args: | NEXT_PUBLIC_UMAMI_HOST=${{ secrets.NEXT_PUBLIC_UMAMI_HOST }} @@ -40,6 +50,16 @@ jobs: - name: Checkout repository 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 uses: docker/login-action@v2 with: @@ -52,8 +72,7 @@ jobs: context: . file: ./Dockerfile.schedule push: true - tags: | - siumauricio/schedule:${{ github.ref_name == 'main' && 'latest' || 'canary' }} + tags: ${{ steps.meta-schedule.outputs.tags }} platforms: linux/amd64 build-and-push-server-image: @@ -63,6 +82,16 @@ jobs: - name: Checkout repository 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 uses: docker/login-action@v2 with: @@ -75,6 +104,5 @@ jobs: context: . file: ./Dockerfile.server push: true - tags: | - siumauricio/server:${{ github.ref_name == 'main' && 'latest' || 'canary' }} + tags: ${{ steps.meta-server.outputs.tags }} platforms: linux/amd64 diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml new file mode 100644 index 000000000..6de85fa27 --- /dev/null +++ b/.github/workflows/pr-quality.yml @@ -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 diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index bfdc8c48b..2ad24fc0c 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -18,7 +18,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 20.16.0 + node-version: 24.4.0 cache: "pnpm" - name: Install Nixpacks diff --git a/.github/workflows/sync-openapi-docs.yml b/.github/workflows/sync-openapi-docs.yml index ddc51355a..549af945b 100644 --- a/.github/workflows/sync-openapi-docs.yml +++ b/.github/workflows/sync-openapi-docs.yml @@ -24,7 +24,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 20.16.0 + node-version: 24.4.0 cache: "pnpm" - name: Install dependencies diff --git a/.gitignore b/.gitignore index ab2fe76c6..d531bab01 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,4 @@ yarn-error.log* *.pem -.db - -# Development environment -.devcontainer \ No newline at end of file +.db \ No newline at end of file diff --git a/.nvmrc b/.nvmrc index 593cb75bc..84e5de6ef 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.16.0 \ No newline at end of file +24.4.0 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4c1f832db..4fa0dd358 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ 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: @@ -11,6 +11,7 @@ We have a few guidelines to follow when contributing to this project: - [Development](#development) - [Build](#build) - [Pull Request](#pull-request) +- [Important Considerations](#important-considerations-for-pull-requests) ## 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. -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 git clone https://github.com/dokploy/dokploy.git @@ -98,7 +99,14 @@ pnpm run dokploy:build ## Docker -To build the docker image +To build the docker image first run commands to copy .env files + +```bash +cp apps/dokploy/.env.production.example .env.production +cp apps/dokploy/.env.production.example apps/dokploy/.env.production +``` + +then run build command ```bash pnpm run docker:build @@ -162,11 +170,13 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0. - 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. -**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. - **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`). +- **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! diff --git a/Dockerfile b/Dockerfile index 5d7bb6770..ed936508f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ # 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 PATH="$PNPM_HOME:$PATH" RUN corepack enable -RUN corepack prepare pnpm@9.12.0 --activate +RUN corepack prepare pnpm@10.22.0 --activate FROM base AS build COPY . /usr/src/app @@ -20,7 +20,7 @@ ENV NODE_ENV=production RUN pnpm --filter=@dokploy/server 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/dist /prod/dokploy/dist @@ -65,4 +65,8 @@ RUN curl -sSL https://railpack.com/install.sh | bash COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack 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"] diff --git a/Dockerfile.cloud b/Dockerfile.cloud index a0de32021..05e7cde49 100644 --- a/Dockerfile.cloud +++ b/Dockerfile.cloud @@ -1,9 +1,9 @@ # 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 PATH="$PNPM_HOME:$PATH" RUN corepack enable -RUN corepack prepare pnpm@9.12.0 --activate +RUN corepack prepare pnpm@10.22.0 --activate FROM base AS build COPY . /usr/src/app @@ -29,7 +29,7 @@ ENV NODE_ENV=production RUN pnpm --filter=@dokploy/server 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/dist /prod/dokploy/dist diff --git a/Dockerfile.schedule b/Dockerfile.schedule index ce1f96edf..81b13fd64 100644 --- a/Dockerfile.schedule +++ b/Dockerfile.schedule @@ -1,9 +1,9 @@ # 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 PATH="$PNPM_HOME:$PATH" RUN corepack enable -RUN corepack prepare pnpm@9.12.0 --activate +RUN corepack prepare pnpm@10.22.0 --activate FROM base AS build COPY . /usr/src/app @@ -20,7 +20,7 @@ ENV NODE_ENV=production RUN pnpm --filter=@dokploy/server 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 diff --git a/Dockerfile.server b/Dockerfile.server index f5aa25c1e..8990ece4d 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -1,9 +1,9 @@ # 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 PATH="$PNPM_HOME:$PATH" RUN corepack enable -RUN corepack prepare pnpm@9.12.0 --activate +RUN corepack prepare pnpm@10.22.0 --activate FROM base AS build COPY . /usr/src/app @@ -20,7 +20,7 @@ ENV NODE_ENV=production RUN pnpm --filter=@dokploy/server 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 diff --git a/README.md b/README.md index 927e6ebc6..2ddc1f498 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies th Dokploy includes multiple features to make your life easier. - **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. - **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. diff --git a/apps/api/.env.example b/apps/api/.env.example index 647e2a077..01edbec0c 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,2 +1,11 @@ LEMON_SQUEEZY_API_KEY="" -LEMON_SQUEEZY_STORE_ID="" \ No newline at end of file +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 \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index ed8269ed0..c7e76afc7 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "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", "typecheck": "tsc --noEmit" }, @@ -12,26 +12,27 @@ "inngest": "3.40.1", "@dokploy/server": "workspace:*", "@hono/node-server": "^1.14.3", - "@hono/zod-validator": "0.3.0", + "@hono/zod-validator": "0.7.6", "dotenv": "^16.4.5", - "hono": "^4.7.10", + "hono": "^4.11.7", "pino": "9.4.0", "pino-pretty": "11.2.2", "react": "18.2.0", "react-dom": "18.2.0", "redis": "4.7.0", - "zod": "^3.25.32" + "zod": "^4.3.6" }, "devDependencies": { - "@types/node": "^20.16.0", + "@types/node": "^24.4.0", "@types/react": "^18.2.37", "@types/react-dom": "^18.2.15", + "rimraf": "6.1.3", "tsx": "^4.16.2", "typescript": "^5.8.3" }, - "packageManager": "pnpm@9.12.0", + "packageManager": "pnpm@10.22.0", "engines": { - "node": "^20.16.0", - "pnpm": ">=9.12.0" + "node": "^24.4.0", + "pnpm": ">=10.22.0" } } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8ddb56dec..0bb6e1401 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -10,6 +10,7 @@ import { type DeployJob, deployJobSchema, } from "./schema.js"; +import { fetchDeploymentJobs } from "./service.js"; import { deploy } from "./utils.js"; const app = new Hono(); @@ -118,7 +119,6 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => { 200, ); } catch (error) { - console.log("error", error); logger.error("Failed to send deployment event", error); return c.json( { @@ -176,6 +176,29 @@ app.get("/health", async (c) => { 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 app.on( ["GET", "POST", "PUT"], diff --git a/apps/api/src/service.ts b/apps/api/src/service.ts new file mode 100644 index 000000000..414ee7d9d --- /dev/null +++ b/apps/api/src/service.ts @@ -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; + 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 => { + 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; + 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, + serverId: string, +): DeploymentJobRow[] { + const requested = events.filter( + (e) => + e.name === "deployment/requested" && + (e.data as Record)?.serverId === serverId, + ); + const rows: DeploymentJobRow[] = []; + + for (const ev of requested) { + const data = (ev.data ?? {}) as Record; + 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 => { + 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)?.serverId === serverId, + ); + // Limit to avoid too many run fetches + const toFetch = requestedForServer.slice(0, 50); + const runsByEventId = new Map(); + + await Promise.all( + toFetch.map(async (ev) => { + const runs = await fetchInngestRunsForEvent(ev.id); + runsByEventId.set(ev.id, runs); + }), + ); + + return buildDeploymentRowsFromRuns(toFetch, runsByEventId, serverId); +}; diff --git a/apps/api/src/utils.ts b/apps/api/src/utils.ts index 13708006a..b99ec492a 100644 --- a/apps/api/src/utils.ts +++ b/apps/api/src/utils.ts @@ -9,7 +9,7 @@ import { updateCompose, updatePreviewDeployment, } from "@dokploy/server"; -import type { DeployJob } from "./schema"; +import type { DeployJob } from "./schema.js"; export const deploy = async (job: DeployJob) => { try { diff --git a/apps/dokploy/.nvmrc b/apps/dokploy/.nvmrc deleted file mode 100644 index 593cb75bc..000000000 --- a/apps/dokploy/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -20.16.0 \ No newline at end of file diff --git a/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts index 097c916ea..6f843b8a8 100644 --- a/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts +++ b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts @@ -32,6 +32,8 @@ describe("Host rule format regression tests", () => { previewDeploymentId: "", internalPath: "/", stripPath: false, + customEntrypoint: null, + middlewares: null, }; describe("Host rule format validation", () => { diff --git a/apps/dokploy/__test__/compose/domain/labels.test.ts b/apps/dokploy/__test__/compose/domain/labels.test.ts index 9a75e0a84..ec8e9edc7 100644 --- a/apps/dokploy/__test__/compose/domain/labels.test.ts +++ b/apps/dokploy/__test__/compose/domain/labels.test.ts @@ -7,6 +7,7 @@ describe("createDomainLabels", () => { const baseDomain: Domain = { host: "example.com", port: 8080, + customEntrypoint: null, https: false, uniqueConfigKey: 1, customCertResolver: null, @@ -21,6 +22,7 @@ describe("createDomainLabels", () => { previewDeploymentId: "", internalPath: "/", stripPath: false, + middlewares: null, }; it("should create basic labels for web entrypoint", async () => { @@ -171,12 +173,12 @@ describe("createDomainLabels", () => { "websecure", ); - // Web entrypoint should have both middlewares with redirect first + // Web entrypoint with HTTPS should only have redirect expect(webLabels).toContain( - "traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,addprefix-test-app-1", + "traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file", ); - // Websecure should only have the addprefix middleware + // Websecure should have the addprefix middleware expect(websecureLabels).toContain( "traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1", ); @@ -208,9 +210,9 @@ describe("createDomainLabels", () => { "traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello", ); - // Should have middlewares in correct order: redirect, stripprefix, addprefix + // Web router with HTTPS should only have redirect expect(webLabels).toContain( - "traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,stripprefix-test-app-1,addprefix-test-app-1", + "traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file", ); }); @@ -240,4 +242,259 @@ describe("createDomainLabels", () => { "traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1", ); }); + + it("should add single custom middleware to router", async () => { + const customMiddlewareDomain = { + ...baseDomain, + middlewares: ["auth@file"], + }; + const labels = await createDomainLabels( + appName, + customMiddlewareDomain, + "web", + ); + + expect(labels).toContain( + "traefik.http.routers.test-app-1-web.middlewares=auth@file", + ); + }); + + it("should add multiple custom middlewares to router", async () => { + const customMiddlewareDomain = { + ...baseDomain, + middlewares: ["auth@file", "rate-limit@file"], + }; + const labels = await createDomainLabels( + appName, + customMiddlewareDomain, + "web", + ); + + expect(labels).toContain( + "traefik.http.routers.test-app-1-web.middlewares=auth@file,rate-limit@file", + ); + }); + + it("should only have redirect on web router when HTTPS is enabled with custom middlewares", async () => { + const combinedDomain = { + ...baseDomain, + https: true, + middlewares: ["auth@file"], + }; + const labels = await createDomainLabels(appName, combinedDomain, "web"); + + // Web router with HTTPS should only redirect, custom middlewares go on websecure + expect(labels).toContain( + "traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file", + ); + expect(labels).not.toContain("auth@file"); + }); + + it("should combine custom middlewares with stripPath middleware (no HTTPS)", async () => { + const combinedDomain = { + ...baseDomain, + path: "/api", + stripPath: true, + middlewares: ["auth@file"], + }; + const labels = await createDomainLabels(appName, combinedDomain, "web"); + + // stripprefix should come before custom middleware + expect(labels).toContain( + "traefik.http.routers.test-app-1-web.middlewares=stripprefix-test-app-1,auth@file", + ); + }); + + it("should only have redirect on web router even with all built-in middlewares and custom middlewares", async () => { + const fullDomain = { + ...baseDomain, + https: true, + path: "/api", + stripPath: true, + internalPath: "/hello", + middlewares: ["auth@file", "rate-limit@file"], + }; + const webLabels = await createDomainLabels(appName, fullDomain, "web"); + + // Web router with HTTPS should only redirect + expect(webLabels).toContain( + "traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file", + ); + // Middleware definitions should still be present (Traefik needs them registered) + expect(webLabels).toContain( + "traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api", + ); + expect(webLabels).toContain( + "traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello", + ); + // But they should NOT be attached to the router + expect(webLabels).not.toContain("stripprefix-test-app-1,"); + expect(webLabels).not.toContain("auth@file"); + expect(webLabels).not.toContain("rate-limit@file"); + }); + + it("should include custom middlewares on websecure entrypoint", async () => { + const customMiddlewareDomain = { + ...baseDomain, + https: true, + middlewares: ["auth@file"], + }; + const websecureLabels = await createDomainLabels( + appName, + customMiddlewareDomain, + "websecure", + ); + + // Websecure should have custom middleware but not redirect-to-https + expect(websecureLabels).toContain( + "traefik.http.routers.test-app-1-websecure.middlewares=auth@file", + ); + expect(websecureLabels).not.toContain("redirect-to-https"); + }); + + it("should NOT include custom middlewares on web router when HTTPS is enabled (only redirect)", async () => { + const domain = { + ...baseDomain, + https: true, + middlewares: ["rate-limit@file", "auth@file"], + }; + const webLabels = await createDomainLabels(appName, domain, "web"); + + // Web router with HTTPS should ONLY have redirect, not custom middlewares + expect(webLabels).toContain( + "traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file", + ); + expect(webLabels).not.toContain("rate-limit@file"); + expect(webLabels).not.toContain("auth@file"); + }); + + it("should create basic labels for custom entrypoint", async () => { + const labels = await createDomainLabels( + appName, + { ...baseDomain, customEntrypoint: "custom" }, + "custom", + ); + expect(labels).toEqual([ + "traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)", + "traefik.http.routers.test-app-1-custom.entrypoints=custom", + "traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080", + "traefik.http.routers.test-app-1-custom.service=test-app-1-custom", + ]); + }); + + it("should create https labels for custom entrypoint", async () => { + const labels = await createDomainLabels( + appName, + { + ...baseDomain, + https: true, + customEntrypoint: "custom", + certificateType: "letsencrypt", + }, + "custom", + ); + expect(labels).toEqual([ + "traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)", + "traefik.http.routers.test-app-1-custom.entrypoints=custom", + "traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080", + "traefik.http.routers.test-app-1-custom.service=test-app-1-custom", + "traefik.http.routers.test-app-1-custom.tls.certresolver=letsencrypt", + ]); + }); + + it("should add stripPath middleware for custom entrypoint", async () => { + const labels = await createDomainLabels( + appName, + { + ...baseDomain, + customEntrypoint: "custom", + path: "/api", + stripPath: true, + }, + "custom", + ); + + expect(labels).toContain( + "traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api", + ); + expect(labels).toContain( + "traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1", + ); + }); + + it("should add internalPath middleware for custom entrypoint", async () => { + const labels = await createDomainLabels( + appName, + { + ...baseDomain, + customEntrypoint: "custom", + internalPath: "/hello", + }, + "custom", + ); + + expect(labels).toContain( + "traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello", + ); + expect(labels).toContain( + "traefik.http.routers.test-app-1-custom.middlewares=addprefix-test-app-1", + ); + }); + + it("should add path prefix in rule for custom entrypoint", async () => { + const labels = await createDomainLabels( + appName, + { + ...baseDomain, + customEntrypoint: "custom", + path: "/api", + }, + "custom", + ); + + expect(labels).toContain( + "traefik.http.routers.test-app-1-custom.rule=Host(`example.com`) && PathPrefix(`/api`)", + ); + }); + + it("should combine all middlewares for custom entrypoint", async () => { + const labels = await createDomainLabels( + appName, + { + ...baseDomain, + customEntrypoint: "custom", + path: "/api", + stripPath: true, + internalPath: "/hello", + }, + "custom", + ); + + expect(labels).toContain( + "traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api", + ); + expect(labels).toContain( + "traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello", + ); + expect(labels).toContain( + "traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1,addprefix-test-app-1", + ); + }); + + it("should not add redirect-to-https for custom entrypoint even with https", async () => { + const labels = await createDomainLabels( + appName, + { + ...baseDomain, + customEntrypoint: "custom", + https: true, + certificateType: "letsencrypt", + }, + "custom", + ); + + const middlewareLabel = labels.find((l) => l.includes(".middlewares=")); + // Should not contain redirect-to-https since there's only one router + expect(middlewareLabel).toBeUndefined(); + }); }); diff --git a/apps/dokploy/__test__/compose/domain/network-service.test.ts b/apps/dokploy/__test__/compose/domain/network-service.test.ts index b8d03c751..83fe8a166 100644 --- a/apps/dokploy/__test__/compose/domain/network-service.test.ts +++ b/apps/dokploy/__test__/compose/domain/network-service.test.ts @@ -4,21 +4,30 @@ import { describe, expect, it } from "vitest"; describe("addDokployNetworkToService", () => { it("should add network to an empty array", () => { const result = addDokployNetworkToService([]); - expect(result).toEqual(["dokploy-network"]); + expect(result).toEqual(["dokploy-network", "default"]); }); it("should not add duplicate network to an array", () => { 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", () => { 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", () => { 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"]); }); }); diff --git a/apps/dokploy/__test__/compose/network/network-root.test.ts b/apps/dokploy/__test__/compose/network/network-root.test.ts index 0d3c841d4..1a6817913 100644 --- a/apps/dokploy/__test__/compose/network/network-root.test.ts +++ b/apps/dokploy/__test__/compose/network/network-root.test.ts @@ -292,7 +292,7 @@ networks: dokploy-network: `; -test("It shoudn't add suffix to dokploy-network", () => { +test("It shouldn't add suffix to dokploy-network", () => { const composeData = parse(composeFile7) as ComposeSpecification; const suffix = generateRandomHash(); diff --git a/apps/dokploy/__test__/compose/network/network-service.test.ts b/apps/dokploy/__test__/compose/network/network-service.test.ts index e07fa1546..073e61615 100644 --- a/apps/dokploy/__test__/compose/network/network-service.test.ts +++ b/apps/dokploy/__test__/compose/network/network-service.test.ts @@ -195,7 +195,7 @@ services: - dokploy-network `; -test("It shoudn't add suffix to dokploy-network in services", () => { +test("It shouldn't add suffix to dokploy-network in services", () => { const composeData = parse(composeFile7) as ComposeSpecification; const suffix = generateRandomHash(); @@ -241,10 +241,10 @@ services: dokploy-network: aliases: - apid - + `; -test("It shoudn't add suffix to dokploy-network in services multiples cases", () => { +test("It shouldn't add suffix to dokploy-network in services multiples cases", () => { const composeData = parse(composeFile8) as ComposeSpecification; const suffix = generateRandomHash(); diff --git a/apps/dokploy/__test__/compose/service/sevice-volumes-from.test.ts b/apps/dokploy/__test__/compose/service/service-volumes-from.test.ts similarity index 100% rename from apps/dokploy/__test__/compose/service/sevice-volumes-from.test.ts rename to apps/dokploy/__test__/compose/service/service-volumes-from.test.ts diff --git a/apps/dokploy/__test__/deploy/application.command.test.ts b/apps/dokploy/__test__/deploy/application.command.test.ts index be29748eb..1a33489b5 100644 --- a/apps/dokploy/__test__/deploy/application.command.test.ts +++ b/apps/dokploy/__test__/deploy/application.command.test.ts @@ -14,13 +14,18 @@ vi.mock("@dokploy/server/db", () => { 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(), + select: vi.fn(() => createChainableMock()), insert: vi.fn(), update: vi.fn(() => createChainableMock()), delete: vi.fn(), @@ -28,6 +33,12 @@ vi.mock("@dokploy/server/db", () => { applications: { findFirst: vi.fn(), }, + patch: { + findMany: vi.fn().mockResolvedValue([]), + }, + member: { + findMany: vi.fn().mockResolvedValue([]), + }, }, }, }; diff --git a/apps/dokploy/__test__/deploy/application.real.test.ts b/apps/dokploy/__test__/deploy/application.real.test.ts index 43ff07836..4adff6f07 100644 --- a/apps/dokploy/__test__/deploy/application.real.test.ts +++ b/apps/dokploy/__test__/deploy/application.real.test.ts @@ -15,13 +15,18 @@ vi.mock("@dokploy/server/db", () => { 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(), + select: vi.fn(() => createChainableMock()), insert: vi.fn(), update: vi.fn(() => createChainableMock()), delete: vi.fn(), @@ -29,6 +34,12 @@ vi.mock("@dokploy/server/db", () => { applications: { findFirst: vi.fn(), }, + patch: { + findMany: vi.fn().mockResolvedValue([]), + }, + member: { + findMany: vi.fn().mockResolvedValue([]), + }, }, }, }; diff --git a/apps/dokploy/__test__/deploy/github.test.ts b/apps/dokploy/__test__/deploy/github.test.ts index 46be44883..d2e773dfc 100644 --- a/apps/dokploy/__test__/deploy/github.test.ts +++ b/apps/dokploy/__test__/deploy/github.test.ts @@ -83,6 +83,14 @@ describe("GitHub Webhook Skip CI", () => { { commits: [{ message: "[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", () => { @@ -99,6 +107,9 @@ describe("GitHub Webhook Skip CI", () => { expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe( "NEW COMMIT", ); + expect(extractCommitMessage({ "x-softserve-event": "push" }, {})).toBe( + "NEW COMMIT", + ); }); }); diff --git a/apps/dokploy/__test__/deploy/soft-serve.test.ts b/apps/dokploy/__test__/deploy/soft-serve.test.ts new file mode 100644 index 000000000..609f15dee --- /dev/null +++ b/apps/dokploy/__test__/deploy/soft-serve.test.ts @@ -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(); + }); +}); diff --git a/apps/dokploy/__test__/drop/drop.test.ts b/apps/dokploy/__test__/drop/drop.test.ts index 777f475ca..a524e8da0 100644 --- a/apps/dokploy/__test__/drop/drop.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.ts @@ -6,6 +6,7 @@ import { paths } from "@dokploy/server/constants"; import AdmZip from "adm-zip"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +const OUTPUT_BASE = "./__test__/drop/zips/output"; const { APPLICATIONS_PATH } = paths(); vi.mock("@dokploy/server/constants", async (importOriginal) => { const actual = await importOriginal(); @@ -13,7 +14,10 @@ vi.mock("@dokploy/server/constants", async (importOriginal) => { // @ts-ignore ...actual, paths: () => ({ - APPLICATIONS_PATH: "./__test__/drop/zips/output", + // @ts-ignore + ...actual.paths(), + BASE_PATH: OUTPUT_BASE, + APPLICATIONS_PATH: OUTPUT_BASE, }), }; }); @@ -148,8 +152,179 @@ const baseApp: ApplicationNested = { dockerContextPath: null, rollbackActive: false, 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", () => { // const { APPLICATIONS_PATH } = paths(); beforeAll(async () => { @@ -166,14 +341,12 @@ describe("unzipDrop using real zip files", () => { try { const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code"); const zip = new AdmZip("./__test__/drop/zips/single-file.zip"); - console.log(`Output Path: ${outputPath}`); const zipBuffer = zip.toBuffer() as Buffer; const file = new File([zipBuffer], "single.zip"); await unzipDrop(file, baseApp); const files = await fs.readdir(outputPath, { withFileTypes: true }); expect(files.some((f) => f.name === "test.txt")).toBe(true); } catch (err) { - console.log(err); } finally { } }); diff --git a/apps/dokploy/__test__/drop/zips/payload/link b/apps/dokploy/__test__/drop/zips/payload/link new file mode 120000 index 000000000..3594e94c0 --- /dev/null +++ b/apps/dokploy/__test__/drop/zips/payload/link @@ -0,0 +1 @@ +/etc/passwd \ No newline at end of file diff --git a/apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip b/apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip new file mode 100644 index 000000000..b30279c6b Binary files /dev/null and b/apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip differ diff --git a/apps/dokploy/__test__/env/stack-environment.test.ts b/apps/dokploy/__test__/env/stack-environment.test.ts index 13f5adb53..773adf3ed 100644 --- a/apps/dokploy/__test__/env/stack-environment.test.ts +++ b/apps/dokploy/__test__/env/stack-environment.test.ts @@ -1,4 +1,4 @@ -import { getEnviromentVariablesObject } from "@dokploy/server/index"; +import { getEnvironmentVariablesObject } from "@dokploy/server/index"; import { describe, expect, it } from "vitest"; const projectEnv = ` @@ -15,7 +15,7 @@ DATABASE_NAME=dev_database SECRET_KEY=env-secret-123 `; -describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => { +describe("getEnvironmentVariablesObject with environment variables (Stack compose)", () => { it("resolves environment variables correctly for Stack compose", () => { const serviceEnv = ` FOO=\${{environment.NODE_ENV}} @@ -23,7 +23,7 @@ BAR=\${{environment.API_URL}} BAZ=test `; - const result = getEnviromentVariablesObject( + const result = getEnvironmentVariablesObject( serviceEnv, projectEnv, environmentEnv, @@ -45,7 +45,7 @@ DATABASE_URL=\${{project.DATABASE_URL}} SERVICE_PORT=4000 `; - const result = getEnviromentVariablesObject( + const result = getEnvironmentVariablesObject( serviceEnv, projectEnv, environmentEnv, @@ -72,7 +72,7 @@ PASSWORD=secret123 DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb `; - const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv); + const result = getEnvironmentVariablesObject(serviceEnv, "", multiRefEnv); expect(result).toEqual({ DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb", @@ -85,7 +85,7 @@ UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}} `; expect(() => - getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv), + getEnvironmentVariablesObject(serviceWithUndefined, "", environmentEnv), ).toThrow("Invalid environment variable: environment.UNDEFINED_VAR"); }); @@ -95,7 +95,7 @@ NODE_ENV=production API_URL=\${{environment.API_URL}} `; - const result = getEnviromentVariablesObject( + const result = getEnvironmentVariablesObject( serviceOverrideEnv, "", environmentEnv, @@ -115,7 +115,7 @@ SERVICE_NAME=my-service COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}} `; - const result = getEnviromentVariablesObject( + const result = getEnvironmentVariablesObject( complexServiceEnv, projectEnv, environmentEnv, @@ -150,7 +150,7 @@ ENV_VAR=\${{environment.API_URL}} DB_NAME=\${{environment.DATABASE_NAME}} `; - const result = getEnviromentVariablesObject( + const result = getEnvironmentVariablesObject( serviceWithConflicts, conflictingProjectEnv, conflictingEnvironmentEnv, @@ -170,7 +170,7 @@ SERVICE_VAR=test PROJECT_VAR=\${{project.ENVIRONMENT}} `; - const result = getEnviromentVariablesObject( + const result = getEnvironmentVariablesObject( serviceWithEmpty, projectEnv, "", diff --git a/apps/dokploy/__test__/permissions/check-permission.test.ts b/apps/dokploy/__test__/permissions/check-permission.test.ts new file mode 100644 index 000000000..7f14e2d0e --- /dev/null +++ b/apps/dokploy/__test__/permissions/check-permission.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockMemberData = ( + role: string, + overrides: Record = {}, +) => ({ + 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 = + 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(); + }); +}); diff --git a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts new file mode 100644 index 000000000..bb6f5f18b --- /dev/null +++ b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts @@ -0,0 +1,79 @@ +import { + enterpriseOnlyResources, + statements, +} from "@dokploy/server/lib/access-control"; +import { describe, expect, it } from "vitest"; + +const FREE_TIER_RESOURCES = [ + "organization", + "member", + "invitation", + "team", + "ac", + "project", + "service", + "environment", + "docker", + "sshKeys", + "gitProviders", + "traefikFiles", + "api", +]; + +const ENTERPRISE_RESOURCES = [ + "volume", + "deployment", + "envVars", + "projectEnvVars", + "environmentEnvVars", + "server", + "registry", + "certificate", + "backup", + "volumeBackup", + "schedule", + "domain", + "destination", + "notification", + "tag", + "logs", + "monitoring", + "auditLog", +]; + +describe("enterpriseOnlyResources set", () => { + it("contains all enterprise resources", () => { + for (const resource of ENTERPRISE_RESOURCES) { + expect(enterpriseOnlyResources.has(resource)).toBe(true); + } + }); + + it("does NOT contain free-tier resources", () => { + for (const resource of FREE_TIER_RESOURCES) { + expect(enterpriseOnlyResources.has(resource)).toBe(false); + } + }); + + it("every resource in statements is either free or enterprise", () => { + const allResources = Object.keys(statements); + for (const resource of allResources) { + const isFree = FREE_TIER_RESOURCES.includes(resource); + const isEnterprise = enterpriseOnlyResources.has(resource); + expect(isFree || isEnterprise).toBe(true); + } + }); + + it("free and enterprise sets don't overlap", () => { + for (const resource of FREE_TIER_RESOURCES) { + expect(enterpriseOnlyResources.has(resource)).toBe(false); + } + }); + + it("all statement resources are accounted for", () => { + const allResources = Object.keys(statements); + const categorized = [...FREE_TIER_RESOURCES, ...ENTERPRISE_RESOURCES]; + for (const resource of allResources) { + expect(categorized).toContain(resource); + } + }); +}); diff --git a/apps/dokploy/__test__/permissions/resolve-permissions.test.ts b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts new file mode 100644 index 000000000..759c8dad8 --- /dev/null +++ b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockMemberData = ( + role: string, + overrides: Record = {}, +) => ({ + 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 = + 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); + }); +}); diff --git a/apps/dokploy/__test__/permissions/service-access.test.ts b/apps/dokploy/__test__/permissions/service-access.test.ts new file mode 100644 index 000000000..b3786807d --- /dev/null +++ b/apps/dokploy/__test__/permissions/service-access.test.ts @@ -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 = + 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(); + }); +}); diff --git a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts index dcaf59d83..fb448e3af 100644 --- a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts +++ b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts @@ -6,6 +6,7 @@ type MockCreateServiceOptions = { TaskTemplate?: { ContainerSpec?: { StopGracePeriod?: number; + Ulimits?: Array<{ Name: string; Soft: number; Hard: number }>; }; }; [key: string]: unknown; @@ -57,6 +58,7 @@ const createApplication = ( }, replicas: 1, stopGracePeriodSwarm: 0n, + ulimitsSwarm: null, serverId: "server-id", ...overrides, }) as unknown as ApplicationNested; @@ -110,4 +112,50 @@ describe("mechanizeDockerContainer", () => { "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); + const call = createServiceMock.mock.calls[0]; + if (!call) { + throw new Error("createServiceMock should have been called once"); + } + const [settings] = call; + 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"); + }); }); diff --git a/apps/dokploy/__test__/setup.ts b/apps/dokploy/__test__/setup.ts index 5af01d147..04fd08b0c 100644 --- a/apps/dokploy/__test__/setup.ts +++ b/apps/dokploy/__test__/setup.ts @@ -12,7 +12,11 @@ vi.mock("@dokploy/server/db", () => { chain.where = () => chain; chain.values = () => chain; chain.returning = () => Promise.resolve([{}]); - chain.then = undefined; + chain.from = () => chain; + chain.innerJoin = () => chain; + chain.then = (resolve: (value: unknown) => void) => { + resolve([]); + }; const tableMock = { findFirst: vi.fn(() => Promise.resolve(undefined)), @@ -21,7 +25,6 @@ vi.mock("@dokploy/server/db", () => { update: vi.fn(() => chain), delete: vi.fn(() => chain), }; - const createQueryMock = () => tableMock; return { db: { diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index b422279ca..e07f34ade 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -48,6 +48,20 @@ const baseSettings: WebServerSettings = { 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, cleanupCacheOnCompose: false, cleanupCacheOnPreviews: false, diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index bbfe54ac8..fe7d0ff9d 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -126,6 +126,7 @@ const baseApp: ApplicationNested = { username: null, dockerContextPath: null, stopGracePeriodSwarm: null, + ulimitsSwarm: null, }; const baseDomain: Domain = { @@ -137,6 +138,7 @@ const baseDomain: Domain = { https: false, path: null, port: null, + customEntrypoint: null, serviceName: "", composeId: "", customCertResolver: null, @@ -145,6 +147,7 @@ const baseDomain: Domain = { previewDeploymentId: "", internalPath: "/", stripPath: false, + middlewares: null, }; const baseRedirect: Redirect = { @@ -264,6 +267,80 @@ test("Websecure entrypoint on https domain with redirect", async () => { expect(router.middlewares).toContain("redirect-test-1"); }); +/** Custom Middlewares */ + +test("Web entrypoint with single custom middleware", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, middlewares: ["auth@file"] }, + "web", + ); + + expect(router.middlewares).toContain("auth@file"); +}); + +test("Web entrypoint with multiple custom middlewares", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, middlewares: ["auth@file", "rate-limit@file"] }, + "web", + ); + + expect(router.middlewares).toContain("auth@file"); + expect(router.middlewares).toContain("rate-limit@file"); +}); + +test("Web entrypoint on https domain with custom middleware", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, https: true, middlewares: ["auth@file"] }, + "web", + ); + + // Should only have HTTPS redirect - custom middleware applies on websecure + expect(router.middlewares).toContain("redirect-to-https"); + expect(router.middlewares).not.toContain("auth@file"); +}); + +test("Websecure entrypoint with custom middleware", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, https: true, middlewares: ["auth@file"] }, + "websecure", + ); + + // Should have custom middleware but not HTTPS redirect + expect(router.middlewares).not.toContain("redirect-to-https"); + expect(router.middlewares).toContain("auth@file"); +}); + +test("Web entrypoint with redirect and custom middleware", async () => { + const router = await createRouterConfig( + { + ...baseApp, + appName: "test", + redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }], + }, + { ...baseDomain, middlewares: ["auth@file"] }, + "web", + ); + + // Should have both redirect middleware and custom middleware + expect(router.middlewares).toContain("redirect-test-1"); + expect(router.middlewares).toContain("auth@file"); +}); + +test("Web entrypoint with empty middlewares array", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, https: false, middlewares: [] }, + "web", + ); + + // Should behave same as no middlewares - no redirect for http + expect(router.middlewares).not.toContain("redirect-to-https"); +}); + /** Certificates */ test("CertificateType on websecure entrypoint", async () => { @@ -275,3 +352,155 @@ test("CertificateType on websecure entrypoint", async () => { expect(router.tls?.certResolver).toBe("letsencrypt"); }); + +test("Custom entrypoint on http domain", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, https: false, customEntrypoint: "custom" }, + "custom", + ); + + expect(router.entryPoints).toEqual(["custom"]); + expect(router.middlewares).not.toContain("redirect-to-https"); + expect(router.tls).toBeUndefined(); +}); + +test("Custom entrypoint on https domain", async () => { + const router = await createRouterConfig( + baseApp, + { + ...baseDomain, + https: true, + customEntrypoint: "custom", + certificateType: "letsencrypt", + }, + "custom", + ); + + expect(router.entryPoints).toEqual(["custom"]); + expect(router.middlewares).not.toContain("redirect-to-https"); + expect(router.tls?.certResolver).toBe("letsencrypt"); +}); + +test("Custom entrypoint with path includes PathPrefix in rule", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, customEntrypoint: "custom", path: "/api" }, + "custom", + ); + + expect(router.rule).toContain("PathPrefix(`/api`)"); + expect(router.entryPoints).toEqual(["custom"]); +}); + +test("Custom entrypoint with stripPath adds stripprefix middleware", async () => { + const router = await createRouterConfig( + baseApp, + { + ...baseDomain, + customEntrypoint: "custom", + path: "/api", + stripPath: true, + }, + "custom", + ); + + expect(router.middlewares).toContain("stripprefix--1"); + expect(router.entryPoints).toEqual(["custom"]); +}); + +test("Custom entrypoint with internalPath adds addprefix middleware", async () => { + const router = await createRouterConfig( + baseApp, + { + ...baseDomain, + customEntrypoint: "custom", + internalPath: "/hello", + }, + "custom", + ); + + expect(router.middlewares).toContain("addprefix--1"); + expect(router.entryPoints).toEqual(["custom"]); +}); + +test("Custom entrypoint with https and custom cert resolver", async () => { + const router = await createRouterConfig( + baseApp, + { + ...baseDomain, + https: true, + customEntrypoint: "custom", + certificateType: "custom", + customCertResolver: "myresolver", + }, + "custom", + ); + + expect(router.entryPoints).toEqual(["custom"]); + expect(router.tls?.certResolver).toBe("myresolver"); +}); + +test("Custom entrypoint without https should not have tls", async () => { + const router = await createRouterConfig( + baseApp, + { + ...baseDomain, + https: false, + customEntrypoint: "custom", + certificateType: "letsencrypt", + }, + "custom", + ); + + expect(router.entryPoints).toEqual(["custom"]); + expect(router.tls).toBeUndefined(); +}); + +/** IDN/Punycode */ + +test("Internationalized domain name is converted to punycode", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, host: "тест.рф" }, + "web", + ); + + // тест.рф in punycode is xn--e1aybc.xn--p1ai + expect(router.rule).toContain("Host(`xn--e1aybc.xn--p1ai`)"); + expect(router.rule).not.toContain("тест.рф"); +}); + +test("ASCII domain remains unchanged", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, host: "example.com" }, + "web", + ); + + expect(router.rule).toContain("Host(`example.com`)"); +}); + +test("Russian Cyrillic label with .ru TLD is converted to punycode", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, host: "сайт.ru" }, + "web", + ); + + // сайт in punycode is xn--80aswg + expect(router.rule).toContain("Host(`xn--80aswg.ru`)"); + expect(router.rule).not.toContain("сайт"); +}); + +test("Subdomain with Russian IDN TLD converts non-ASCII part to punycode", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, host: "app.тест.рф" }, + "web", + ); + + // app stays ASCII, тест.рф becomes xn--e1aybc.xn--p1ai + expect(router.rule).toContain("Host(`app.xn--e1aybc.xn--p1ai`)"); + expect(router.rule).not.toContain("тест.рф"); +}); diff --git a/apps/dokploy/__test__/wss/readValidDirectory.test.ts b/apps/dokploy/__test__/wss/readValidDirectory.test.ts new file mode 100644 index 000000000..8107bb591 --- /dev/null +++ b/apps/dokploy/__test__/wss/readValidDirectory.test.ts @@ -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(); + 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); + }); +}); diff --git a/apps/dokploy/__test__/wss/utils.test.ts b/apps/dokploy/__test__/wss/utils.test.ts new file mode 100644 index 000000000..209bd5f86 --- /dev/null +++ b/apps/dokploy/__test__/wss/utils.test.ts @@ -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); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx index ee427feca..81a09ec0f 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx @@ -22,6 +22,7 @@ import { HealthCheckForm, LabelsForm, ModeForm, + NetworkForm, PlacementForm, RestartPolicyForm, RollbackConfigForm, @@ -79,6 +80,13 @@ const menuItems: MenuItem[] = [ docDescription: "Set service mode to either 'Replicated' with a specified number of tasks (Replicas), or 'Global' (one task per node).", }, + { + id: "network", + label: "Network", + description: "Configure network attachments", + docDescription: + "Attach the service to one or more networks. Specify the network name (Target) and optional network aliases for service discovery.", + }, { id: "labels", label: "Labels", @@ -102,16 +110,16 @@ const menuItems: MenuItem[] = [ }, ]; -const hasStopGracePeriodSwarm = ( - value: unknown, -): value is { stopGracePeriodSwarm: bigint | number | string | null } => - typeof value === "object" && - value !== null && - "stopGracePeriodSwarm" in value; - interface Props { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: + | "application" + | "libsql" + | "mariadb" + | "mongo" + | "mysql" + | "postgres" + | "redis"; } export const AddSwarmSettings = ({ id, type }: Props) => { @@ -190,6 +198,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => { )} {activeMenu === "mode" && } + {activeMenu === "network" && } {activeMenu === "labels" && } {activeMenu === "stop-grace-period" && ( diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx index a3bc8079a..95f849480 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx @@ -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 Link from "next/link"; import { useEffect } from "react"; @@ -37,27 +37,27 @@ import { AddSwarmSettings } from "./modify-swarm-settings"; interface Props { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: "application" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis"; } -const AddRedirectchema = z.object({ +const AddRedirectSchema = z.object({ replicas: z.number().min(1, "Replicas must be at least 1"), registryId: z.string().optional(), }); -type AddCommand = z.infer; +type AddCommand = z.infer; export const ShowClusterSettings = ({ id, type }: Props) => { 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: () => 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 }), }; const { data, refetch } = queryMap[type] ? queryMap[type]() @@ -65,15 +65,16 @@ export const ShowClusterSettings = ({ id, type }: Props) => { const { data: registries } = api.registry.all.useQuery(); 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(), 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]() : api.mongo.update.useMutation(); @@ -86,7 +87,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => { : {}), replicas: data?.replicas || 1, }, - resolver: zodResolver(AddRedirectchema), + resolver: zodResolver(AddRedirectSchema), }); useEffect(() => { @@ -105,11 +106,11 @@ export const ShowClusterSettings = ({ id, type }: Props) => { const onSubmit = async (data: AddCommand) => { await mutateAsync({ applicationId: id || "", - postgresId: id || "", - redisId: id || "", - mysqlId: id || "", mariadbId: id || "", mongoId: id || "", + mysqlId: id || "", + postgresId: id || "", + redisId: id || "", ...(type === "application" ? { registryId: @@ -236,7 +237,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => { )}
-
diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/endpoint-spec-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/endpoint-spec-form.tsx index 7ee31e5b6..6ea18c653 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/endpoint-spec-form.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/endpoint-spec-form.tsx @@ -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 { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -28,7 +28,14 @@ export const endpointSpecFormSchema = z.object({ interface EndpointSpecFormProps { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: + | "postgres" + | "mariadb" + | "mongo" + | "mysql" + | "redis" + | "application" + | "libsql"; } export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => { @@ -44,6 +51,7 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => { 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]() @@ -56,6 +64,7 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => { mariadb: () => api.mariadb.update.useMutation(), application: () => api.application.update.useMutation(), mongo: () => api.mongo.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), }; const { mutateAsync } = mutationMap[type] @@ -94,6 +103,7 @@ export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => { mysqlId: id || "", mariadbId: id || "", mongoId: id || "", + libsqlId: id || "", endpointSpecSwarm: hasAnyValue ? formData : null, }); diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/health-check-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/health-check-form.tsx index 1e0d032f0..06c8eb94a 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/health-check-form.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/health-check-form.tsx @@ -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 { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -26,7 +26,14 @@ export const healthCheckFormSchema = z.object({ interface HealthCheckFormProps { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: + | "postgres" + | "mariadb" + | "mongo" + | "mysql" + | "redis" + | "application" + | "libsql"; } export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => { @@ -42,6 +49,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => { 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]() @@ -54,6 +62,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => { mariadb: () => api.mariadb.update.useMutation(), application: () => api.application.update.useMutation(), mongo: () => api.mongo.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), }; const { mutateAsync } = mutationMap[type] @@ -104,6 +113,7 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => { mysqlId: id || "", mariadbId: id || "", mongoId: id || "", + libsqlId: id || "", healthCheckSwarm: hasAnyValue ? formData : null, }); diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts index 2f07be53d..df972102d 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/index.ts @@ -2,6 +2,7 @@ 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"; diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/labels-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/labels-form.tsx index d1681dcd0..02a480a03 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/labels-form.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/labels-form.tsx @@ -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 { useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -29,7 +29,14 @@ export const labelsFormSchema = z.object({ interface LabelsFormProps { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: + | "postgres" + | "mariadb" + | "mongo" + | "mysql" + | "redis" + | "application" + | "libsql"; } export const LabelsForm = ({ id, type }: LabelsFormProps) => { @@ -45,6 +52,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => { 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]() @@ -57,6 +65,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => { mariadb: () => api.mariadb.update.useMutation(), application: () => api.application.update.useMutation(), mongo: () => api.mongo.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), }; const { mutateAsync } = mutationMap[type] @@ -112,6 +121,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => { mysqlId: id || "", mariadbId: id || "", mongoId: id || "", + libsqlId: id || "", labelsSwarm: labelsToSend, }); diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/mode-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/mode-form.tsx index 839f5d519..bd2eca18e 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/mode-form.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/mode-form.tsx @@ -23,7 +23,14 @@ import { api } from "@/utils/api"; interface ModeFormProps { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: + | "postgres" + | "mariadb" + | "mongo" + | "mysql" + | "redis" + | "application" + | "libsql"; } export const ModeForm = ({ id, type }: ModeFormProps) => { @@ -39,6 +46,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => { 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]() @@ -51,6 +59,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => { mariadb: () => api.mariadb.update.useMutation(), application: () => api.application.update.useMutation(), mongo: () => api.mongo.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), }; const { mutateAsync } = mutationMap[type] @@ -95,6 +104,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => { mysqlId: id || "", mariadbId: id || "", mongoId: id || "", + libsqlId: id || "", modeSwarm: null, }); toast.success("Mode updated successfully"); @@ -105,7 +115,14 @@ export const ModeForm = ({ id, type }: ModeFormProps) => { const modeData = formData.type === "Replicated" - ? { Replicated: { Replicas: formData.Replicas } } + ? { + Replicated: { + Replicas: + formData.Replicas !== undefined && formData.Replicas !== "" + ? Number(formData.Replicas) + : undefined, + }, + } : { Global: {} }; await mutateAsync({ @@ -115,6 +132,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => { mysqlId: id || "", mariadbId: id || "", mongoId: id || "", + libsqlId: id || "", modeSwarm: modeData, }); diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/network-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/network-form.tsx new file mode 100644 index 000000000..269d6f784 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/network-form.tsx @@ -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>({ + 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) => { + 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 ( +
+ +
+ Networks + + Configure network attachments for your service + +
+ {fields.map((field, index) => ( +
+ ( + + Network Name + + + + + The name of the network to attach to + + + + )} + /> + ( + + Aliases (optional) + + + + + Comma-separated list of network aliases + + + + )} + /> +
+ Driver options (optional) + + e.g. com.docker.network.driver.mtu, + com.docker.network.driver.host_binding + + {( + form.watch(`networks.${index}.DriverOptsEntries`) ?? [] + ).map((_, optIndex) => ( +
+ ( + + + + + + + )} + /> + ( + + + + + + + )} + /> + +
+ ))} + +
+ +
+ ))} + +
+
+ +
+ + +
+
+ + ); +}; diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/placement-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/placement-form.tsx index 25a72b3c9..a4a650020 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/placement-form.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/placement-form.tsx @@ -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 { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -34,7 +34,14 @@ export const placementFormSchema = z.object({ interface PlacementFormProps { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: + | "postgres" + | "mariadb" + | "mongo" + | "mysql" + | "redis" + | "application" + | "libsql"; } export const PlacementForm = ({ id, type }: PlacementFormProps) => { @@ -50,6 +57,7 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => { 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]() @@ -62,6 +70,7 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => { mariadb: () => api.mariadb.update.useMutation(), application: () => api.application.update.useMutation(), mongo: () => api.mongo.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), }; const { mutateAsync } = mutationMap[type] @@ -114,6 +123,7 @@ export const PlacementForm = ({ id, type }: PlacementFormProps) => { mysqlId: id || "", mariadbId: id || "", mongoId: id || "", + libsqlId: id || "", placementSwarm: hasAnyValue ? { ...formData, diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/restart-policy-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/restart-policy-form.tsx index b7fb649be..4aba01f03 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/restart-policy-form.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/restart-policy-form.tsx @@ -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 { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -32,7 +32,14 @@ export const restartPolicyFormSchema = z.object({ interface RestartPolicyFormProps { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: + | "postgres" + | "mariadb" + | "mongo" + | "mysql" + | "redis" + | "application" + | "libsql"; } export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => { @@ -48,6 +55,7 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => { 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]() @@ -60,6 +68,7 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => { mariadb: () => api.mariadb.update.useMutation(), application: () => api.application.update.useMutation(), mongo: () => api.mongo.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), }; const { mutateAsync } = mutationMap[type] @@ -104,6 +113,7 @@ export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => { mysqlId: id || "", mariadbId: id || "", mongoId: id || "", + libsqlId: id || "", restartPolicySwarm: hasAnyValue ? formData : null, }); diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/rollback-config-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/rollback-config-form.tsx index d53215348..081825e64 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/rollback-config-form.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/rollback-config-form.tsx @@ -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 { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -34,7 +34,14 @@ export const rollbackConfigFormSchema = z.object({ interface RollbackConfigFormProps { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: + | "postgres" + | "mariadb" + | "mongo" + | "mysql" + | "redis" + | "application" + | "libsql"; } export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => { @@ -50,6 +57,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => { 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]() @@ -62,6 +70,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => { mariadb: () => api.mariadb.update.useMutation(), application: () => api.application.update.useMutation(), mongo: () => api.mongo.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), }; const { mutateAsync } = mutationMap[type] @@ -103,6 +112,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => { mysqlId: id || "", mariadbId: id || "", mongoId: id || "", + libsqlId: id || "", rollbackConfigSwarm: (hasAnyValue ? formData : null) as any, }); diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/stop-grace-period-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/stop-grace-period-form.tsx index a324da31b..58b36fbae 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/stop-grace-period-form.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/stop-grace-period-form.tsx @@ -23,7 +23,14 @@ const hasStopGracePeriodSwarm = ( interface StopGracePeriodFormProps { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: + | "postgres" + | "mariadb" + | "mongo" + | "mysql" + | "redis" + | "application" + | "libsql"; } export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => { @@ -39,6 +46,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => { 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]() @@ -51,6 +59,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => { mariadb: () => api.mariadb.update.useMutation(), application: () => api.application.update.useMutation(), mongo: () => api.mongo.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), }; const { mutateAsync } = mutationMap[type] @@ -88,6 +97,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => { mysqlId: id || "", mariadbId: id || "", mongoId: id || "", + libsqlId: id || "", stopGracePeriodSwarm: formData.value, }); diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/update-config-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/update-config-form.tsx index 4119c41f8..ef9fe34bb 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/update-config-form.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/update-config-form.tsx @@ -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 { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -34,7 +34,14 @@ export const updateConfigFormSchema = z.object({ interface UpdateConfigFormProps { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: + | "postgres" + | "mariadb" + | "mongo" + | "mysql" + | "redis" + | "application" + | "libsql"; } export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => { @@ -50,6 +57,7 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => { 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]() @@ -62,6 +70,7 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => { mariadb: () => api.mariadb.update.useMutation(), application: () => api.application.update.useMutation(), mongo: () => api.mongo.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), }; const { mutateAsync } = mutationMap[type] @@ -109,6 +118,7 @@ export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => { mysqlId: id || "", mariadbId: id || "", mongoId: id || "", + libsqlId: id || "", updateConfigSwarm: (hasAnyValue ? formData : null) as any, }); diff --git a/apps/dokploy/components/dashboard/application/advanced/general/add-command.tsx b/apps/dokploy/components/dashboard/application/advanced/general/add-command.tsx index a7c5f7288..602e6877d 100644 --- a/apps/dokploy/components/dashboard/application/advanced/general/add-command.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/general/add-command.tsx @@ -1,4 +1,4 @@ -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 { useFieldArray, useForm } from "react-hook-form"; @@ -50,7 +50,7 @@ export const AddCommand = ({ applicationId }: Props) => { const utils = api.useUtils(); - const { mutateAsync, isLoading } = api.application.update.useMutation(); + const { mutateAsync, isPending } = api.application.update.useMutation(); const form = useForm({ defaultValues: { @@ -177,7 +177,7 @@ export const AddCommand = ({ applicationId }: Props) => {
-
diff --git a/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx b/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx index 17d033cf2..7b1614fda 100644 --- a/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/import/show-import.tsx @@ -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 { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -69,11 +69,11 @@ export const ShowImport = ({ composeId }: Props) => { } | null>(null); const utils = api.useUtils(); - const { mutateAsync: processTemplate, isLoading: isLoadingTemplate } = + const { mutateAsync: processTemplate, isPending: isLoadingTemplate } = api.compose.processTemplate.useMutation(); const { mutateAsync: importTemplate, - isLoading: isImporting, + isPending: isImporting, isSuccess: isImportSuccess, } = api.compose.import.useMutation(); diff --git a/apps/dokploy/components/dashboard/application/advanced/ports/handle-ports.tsx b/apps/dokploy/components/dashboard/application/advanced/ports/handle-ports.tsx index 568792461..91570d2db 100644 --- a/apps/dokploy/components/dashboard/application/advanced/ports/handle-ports.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/ports/handle-ports.tsx @@ -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 { useEffect, useState } from "react"; import { useForm, useWatch } from "react-hook-form"; @@ -35,13 +35,9 @@ import { api } from "@/utils/api"; const AddPortSchema = z.object({ publishedPort: z.number().int().min(1).max(65535), - publishMode: z.enum(["ingress", "host"], { - required_error: "Publish mode is required", - }), + publishMode: z.enum(["ingress", "host"]), targetPort: z.number().int().min(1).max(65535), - protocol: z.enum(["tcp", "udp"], { - required_error: "Protocol is required", - }), + protocol: z.enum(["tcp", "udp"]), }); type AddPort = z.infer; @@ -68,7 +64,7 @@ export const HandlePorts = ({ enabled: !!portId, }, ); - const { mutateAsync, isLoading, error, isError } = portId + const { mutateAsync, isPending, error, isError } = portId ? api.port.update.useMutation() : api.port.create.useMutation(); @@ -270,7 +266,7 @@ export const HandlePorts = ({ diff --git a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx index aea30e49b..fa2bda629 100644 --- a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx @@ -1,7 +1,7 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { InfoIcon } from "lucide-react"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { InfoIcon, Plus, Trash2 } from "lucide-react"; import { useEffect } from "react"; -import { useForm } from "react-hook-form"; +import { useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; @@ -21,10 +21,18 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; +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 { Tooltip, TooltipContent, @@ -50,20 +58,44 @@ const memoryConverter = createConverter(1024 * 1024, (mb) => { : `${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({ memoryReservation: z.string().optional(), cpuLimit: z.string().optional(), memoryLimit: 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 = - | "postgres" - | "mongo" - | "redis" - | "mysql" + | "application" + | "libsql" | "mariadb" - | "application"; + | "mongo" + | "mysql" + | "postgres" + | "redis"; interface Props { id: string; @@ -74,43 +106,51 @@ type AddResources = z.infer; export const ShowResources = ({ id, type }: Props) => { 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: () => 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 }), }; const { data, refetch } = queryMap[type] ? queryMap[type]() : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); 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(), 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]() : api.mongo.update.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { cpuLimit: "", cpuReservation: "", memoryLimit: "", memoryReservation: "", + ulimitsSwarm: [], }, resolver: zodResolver(addResourcesSchema), }); + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "ulimitsSwarm", + }); + useEffect(() => { if (data) { form.reset({ @@ -118,22 +158,28 @@ export const ShowResources = ({ id, type }: Props) => { cpuReservation: data?.cpuReservation || undefined, memoryLimit: data?.memoryLimit || undefined, memoryReservation: data?.memoryReservation || undefined, + ulimitsSwarm: (data as any)?.ulimitsSwarm || [], }); } }, [data, form, form.reset]); const onSubmit = async (formData: AddResources) => { await mutateAsync({ + applicationId: id || "", + libsqlId: id || "", + mariadbId: id || "", mongoId: id || "", + mysqlId: id || "", postgresId: id || "", redisId: id || "", - mysqlId: id || "", - mariadbId: id || "", - applicationId: id || "", cpuLimit: formData.cpuLimit || null, cpuReservation: formData.cpuReservation || null, memoryLimit: formData.memoryLimit || null, memoryReservation: formData.memoryReservation || null, + ulimitsSwarm: + formData.ulimitsSwarm && formData.ulimitsSwarm.length > 0 + ? formData.ulimitsSwarm + : null, }) .then(async () => { toast.success("Resources Updated"); @@ -325,8 +371,157 @@ export const ShowResources = ({ id, type }: Props) => { }} /> + + {/* Ulimits Section */} +
+
+
+ Ulimits + + + + + + +

+ Set resource limits for the container. Each ulimit has + a soft limit (warning threshold) and hard limit + (maximum allowed). Use -1 for unlimited. +

+
+
+
+
+ +
+ + {fields.length > 0 && ( +
+ {fields.map((field, index) => ( +
+ ( + + Type + + + + )} + /> + ( + + + Soft Limit + + + + field.onChange(Number(e.target.value)) + } + /> + + + + )} + /> + ( + + + Hard Limit + + + + field.onChange(Number(e.target.value)) + } + /> + + + + )} + /> + +
+ ))} +
+ )} + + {fields.length === 0 && ( +

+ No ulimits configured. Click "Add Ulimit" to set + resource limits. +

+ )} +
+
-
diff --git a/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx b/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx index ae23f1866..94efbc285 100644 --- a/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx @@ -15,13 +15,17 @@ interface 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, }, - { enabled: !!applicationId }, + { enabled: !!applicationId && canRead }, ); + if (!canRead) return null; + return ( @@ -35,7 +39,7 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => { - {isLoading ? ( + {isPending ? ( Loading... diff --git a/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx b/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx index bf3d5d9bc..b3646803c 100644 --- a/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx @@ -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 { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -7,6 +7,7 @@ import { z } from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; import { CodeEditor } from "@/components/shared/code-editor"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, @@ -24,6 +25,7 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; +import { Label } from "@/components/ui/label"; import { api } from "@/utils/api"; const UpdateTraefikConfigSchema = z.object({ @@ -58,7 +60,10 @@ export const validateAndFormatYAML = (yamlText: string) => { }; export const UpdateTraefikConfig = ({ applicationId }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canWrite = permissions?.traefikFiles.write ?? false; const [open, setOpen] = useState(false); + const [skipYamlValidation, setSkipYamlValidation] = useState(false); const { data, refetch } = api.application.readTraefikConfig.useQuery( { applicationId, @@ -66,7 +71,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => { { enabled: !!applicationId }, ); - const { mutateAsync, isLoading, error, isError } = + const { mutateAsync, isPending, error, isError } = api.application.updateTraefikConfig.useMutation(); const form = useForm({ @@ -85,13 +90,15 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => { }, [data]); const onSubmit = async (data: UpdateTraefikConfig) => { - const { valid, error } = validateAndFormatYAML(data.traefikConfig); - if (!valid) { - form.setError("traefikConfig", { - type: "manual", - message: (error as string) || "Invalid YAML", - }); - return; + if (!skipYamlValidation) { + const { valid, error } = validateAndFormatYAML(data.traefikConfig); + if (!valid) { + form.setError("traefikConfig", { + type: "manual", + message: (error as string) || "Invalid YAML", + }); + return; + } } form.clearErrors("traefikConfig"); await mutateAsync({ @@ -116,12 +123,15 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => { setOpen(open); if (!open) { form.reset(); + setSkipYamlValidation(false); } }} > - - - + {canWrite && ( + + + + )} Update traefik config @@ -169,9 +179,30 @@ routers: - + +
+
+ + setSkipYamlValidation(checked === true) + } + /> + +
+

+ Check to save configs with Go templating (e.g.{" "} + {"{{range}}"}). +

+
- + + + )} diff --git a/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx b/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx index 44fb050bc..882123efb 100644 --- a/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx @@ -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 { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -67,13 +67,13 @@ interface Props { refetch: () => void; serviceType: | "application" - | "postgres" - | "redis" - | "mongo" - | "redis" - | "mysql" + | "compose" + | "libsql" | "mariadb" - | "compose"; + | "mongo" + | "mysql" + | "postgres" + | "redis"; } export const UpdateVolume = ({ @@ -93,7 +93,7 @@ export const UpdateVolume = ({ }, ); - const { mutateAsync, isLoading, error, isError } = + const { mutateAsync, isPending, error, isError } = api.mounts.update.useMutation(); const form = useForm({ @@ -187,7 +187,7 @@ export const UpdateVolume = ({ variant="ghost" size="icon" className="group hover:bg-blue-500/10 " - isLoading={isLoading} + isLoading={isPending} > @@ -253,7 +253,7 @@ export const UpdateVolume = ({ control={form.control} name="content" render={({ field }) => ( - + Content @@ -310,7 +310,7 @@ PORT=3000 diff --git a/apps/dokploy/components/dashboard/application/deployments/cancel-queues.tsx b/apps/dokploy/components/dashboard/application/deployments/cancel-queues.tsx index e957a496c..ed1373aa0 100644 --- a/apps/dokploy/components/dashboard/application/deployments/cancel-queues.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/cancel-queues.tsx @@ -1,4 +1,4 @@ -import { Paintbrush } from "lucide-react"; +import { Ban } from "lucide-react"; import { toast } from "sonner"; import { AlertDialog, @@ -20,7 +20,7 @@ interface Props { } export const CancelQueues = ({ id, type }: Props) => { - const { mutateAsync, isLoading } = + const { mutateAsync, isPending } = type === "application" ? api.application.cleanQueues.useMutation() : api.compose.cleanQueues.useMutation(); @@ -33,9 +33,9 @@ export const CancelQueues = ({ id, type }: Props) => { return ( - diff --git a/apps/dokploy/components/dashboard/application/deployments/clear-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/clear-deployments.tsx new file mode 100644 index 000000000..81f998a9d --- /dev/null +++ b/apps/dokploy/components/dashboard/application/deployments/clear-deployments.tsx @@ -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 ( + + + + + + + + Are you sure you want to clear old deployments? + + + This will delete all old deployment records and logs, keeping only + the active deployment (the most recent successful one). + + + + Cancel + { + 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 + + + + + ); +}; diff --git a/apps/dokploy/components/dashboard/application/deployments/kill-build.tsx b/apps/dokploy/components/dashboard/application/deployments/kill-build.tsx index 784534dd6..ad5e9b058 100644 --- a/apps/dokploy/components/dashboard/application/deployments/kill-build.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/kill-build.tsx @@ -20,7 +20,7 @@ interface Props { } export const KillBuild = ({ id, type }: Props) => { - const { mutateAsync, isLoading } = + const { mutateAsync, isPending } = type === "application" ? api.application.killBuild.useMutation() : api.compose.killBuild.useMutation(); @@ -28,7 +28,7 @@ export const KillBuild = ({ id, type }: Props) => { return ( - diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx index 0d403ecd2..4285f04c4 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx @@ -194,13 +194,21 @@ export const ShowDeployment = ({ {" "} {filteredLogs.length > 0 ? ( filteredLogs.map((log: LogLine, index: number) => ( - + )) ) : ( <> {optionalErrors.length > 0 ? ( optionalErrors.map((log: LogLine, index: number) => ( - + )) ) : (
diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index cfe747d27..ccf2564b0 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -1,11 +1,14 @@ +import copy from "copy-to-clipboard"; import { ChevronDown, ChevronUp, Clock, + Copy, Loader2, RefreshCcw, RocketIcon, Settings, + Trash2, } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -25,6 +28,7 @@ import { import { api, type RouterOutputs } from "@/utils/api"; import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings"; import { CancelQueues } from "./cancel-queues"; +import { ClearDeployments } from "./clear-deployments"; import { KillBuild } from "./kill-build"; import { RefreshToken } from "./refresh-token"; import { ShowDeployment } from "./show-deployment"; @@ -59,7 +63,7 @@ export const ShowDeployments = ({ const [activeLog, setActiveLog] = useState< RouterOutputs["deployment"]["all"][number] | null >(null); - const { data: deployments, isLoading: isLoadingDeployments } = + const { data: deployments, isPending: isLoadingDeployments } = api.deployment.allByType.useQuery( { id, @@ -73,19 +77,21 @@ export const ShowDeployments = ({ const { data: isCloud } = api.settings.isCloud.useQuery(); - const { mutateAsync: rollback, isLoading: isRollingBack } = + const { mutateAsync: rollback, isPending: isRollingBack } = api.rollback.rollback.useMutation(); - const { mutateAsync: killProcess, isLoading: isKillingProcess } = + const { mutateAsync: killProcess, isPending: isKillingProcess } = api.deployment.killProcess.useMutation(); + const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } = + api.deployment.removeDeployment.useMutation(); // Cancel deployment mutations const { mutateAsync: cancelApplicationDeployment, - isLoading: isCancellingApp, + isPending: isCancellingApp, } = api.application.cancelDeployment.useMutation(); const { mutateAsync: cancelComposeDeployment, - isLoading: isCancellingCompose, + isPending: isCancellingCompose, } = api.compose.cancelDeployment.useMutation(); const [url, setUrl] = React.useState(""); @@ -93,6 +99,12 @@ export const ShowDeployments = ({ 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 => { @@ -144,6 +156,9 @@ export const ShowDeployments = ({
+ {(type === "application" || type === "compose") && ( + + )} {(type === "application" || type === "compose") && ( )} @@ -217,11 +232,27 @@ export const ShowDeployments = ({
Webhook URL:
- - {`${url}/api/deploy${ - type === "compose" ? "/compose" : "" - }/${refreshToken}`} - + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + copy(webhookUrl); + toast.success("Copied to clipboard."); + } + }} + onClick={() => { + copy(webhookUrl); + toast.success("Copied to clipboard."); + }} + > + {webhookUrl} + + {(type === "application" || type === "compose") && ( )} @@ -252,6 +283,8 @@ export const ShowDeployments = ({ const isExpanded = expandedDescriptions.has( deployment.deploymentId, ); + const canDelete = + deployment.status === "done" || deployment.status === "error"; return (
+ {canDelete && ( + { + try { + await removeDeployment({ + deploymentId: deployment.deploymentId, + }); + toast.success("Deployment deleted successfully"); + } catch (error) { + toast.error("Error deleting deployment"); + } + }} + > + + + )} + {deployment?.rollback && deployment.status === "done" && type === "application" && ( diff --git a/apps/dokploy/components/dashboard/application/domains/columns.tsx b/apps/dokploy/components/dashboard/application/domains/columns.tsx new file mode 100644 index 000000000..cd8254aa0 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/domains/columns.tsx @@ -0,0 +1,303 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import { + ArrowUpDown, + CheckCircle2, + ExternalLink, + Loader2, + PenBoxIcon, + RefreshCw, + Server, + Trash2, + XCircle, +} from "lucide-react"; +import Link from "next/link"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { RouterOutputs } from "@/utils/api"; +import type { ValidationStates } from "./show-domains"; +import { AddDomain } from "./handle-domain"; +import { DnsHelperModal } from "./dns-helper-modal"; + +export type Domain = + | RouterOutputs["domain"]["byApplicationId"][0] + | RouterOutputs["domain"]["byComposeId"][0]; + +interface ColumnsProps { + id: string; + type: "application" | "compose"; + validationStates: ValidationStates; + handleValidateDomain: (host: string) => Promise; + handleDeleteDomain: (domainId: string) => Promise; + isDeleting: boolean; + serverIp?: string; + canCreateDomain: boolean; + canDeleteDomain: boolean; +} + +export const createColumns = ({ + id, + type, + validationStates, + handleValidateDomain, + handleDeleteDomain, + isDeleting, + serverIp, + canCreateDomain, + canDeleteDomain, +}: ColumnsProps): ColumnDef[] => [ + ...(type === "compose" + ? [ + { + accessorKey: "serviceName", + header: "Service", + cell: ({ row }: { row: { getValue: (key: string) => unknown } }) => { + const serviceName = row.getValue("serviceName") as string | null; + if (!serviceName) return null; + return ( + + + {serviceName} + + ); + }, + } satisfies ColumnDef, + ] + : []), + { + accessorKey: "host", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const domain = row.original; + return ( + + {domain.host} + + + ); + }, + }, + { + accessorKey: "path", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const path = row.getValue("path") as string; + return
{path || "/"}
; + }, + }, + { + accessorKey: "port", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const port = row.getValue("port") as number; + return {port}; + }, + }, + { + accessorKey: "customEntrypoint", + header: "Entrypoint", + cell: ({ row }) => { + const entrypoint = row.getValue("customEntrypoint") as string | null; + if (!entrypoint) return -; + return
{entrypoint}
; + }, + }, + { + accessorKey: "https", + header: "Protocol", + cell: ({ row }) => { + const https = row.getValue("https") as boolean; + return ( + + {https ? "HTTPS" : "HTTP"} + + ); + }, + }, + { + id: "certificate", + header: "Certificate", + cell: ({ row }) => { + const domain = row.original; + const validationState = validationStates[domain.host]; + + return ( +
+ {domain.certificateType && ( + + {domain.certificateType} + + )} + {!domain.host.includes("traefik.me") && ( + + + + handleValidateDomain(domain.host)} + > + {validationState?.isLoading ? ( + <> + + Checking... + + ) : validationState?.isValid ? ( + <> + + {validationState.message && validationState.cdnProvider + ? `${validationState.cdnProvider}` + : "Valid"} + + ) : validationState?.error ? ( + <> + + Invalid + + ) : ( + <> + + Validate + + )} + + + + {validationState?.error ? ( +
+

Error:

+

{validationState.error}

+
+ ) : ( + "Click to validate DNS configuration" + )} +
+
+
+ )} +
+ ); + }, + }, + { + accessorKey: "createdAt", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const createdAt = row.getValue("createdAt") as string; + return ( +
+ {new Date(createdAt).toLocaleDateString()} +
+ ); + }, + }, + { + id: "actions", + header: "Actions", + enableHiding: false, + cell: ({ row }) => { + const domain = row.original; + + return ( +
+ {!domain.host.includes("traefik.me") && ( + + )} + {canCreateDomain && ( + + + + )} + {canDeleteDomain && ( + { + await handleDeleteDomain(domain.domainId); + }} + > + + + )} +
+ ); + }, + }, +]; diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index 6af0e1e8c..655f9d147 100644 --- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -1,11 +1,12 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { DatabaseZap, Dices, RefreshCw } from "lucide-react"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import z from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -61,11 +62,14 @@ export const domain = z .min(1, { message: "Port must be at least 1" }) .max(65535, { message: "Port must be 65535 or below" }) .optional(), + useCustomEntrypoint: z.boolean(), + customEntrypoint: z.string().optional(), https: z.boolean().optional(), certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), customCertResolver: z.string().optional(), serviceName: z.string().optional(), domainType: z.enum(["application", "compose", "preview"]).optional(), + middlewares: z.array(z.string()).optional(), }) .superRefine((input, ctx) => { if (input.https && !input.certificateType) { @@ -114,6 +118,14 @@ export const domain = z message: "Internal path must start with '/'", }); } + + if (input.useCustomEntrypoint && !input.customEntrypoint) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["customEntrypoint"], + message: "Custom entry point must be specified", + }); + } }); type Domain = z.infer; @@ -159,11 +171,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.create.useMutation(); - const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } = + const { mutateAsync: generateDomain, isPending: isLoadingGenerate } = api.domain.generateDomain.useMutation(); const { data: canGenerateTraefikMeDomains } = @@ -196,16 +208,20 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { internalPath: undefined, stripPath: false, port: undefined, + useCustomEntrypoint: false, + customEntrypoint: undefined, https: false, certificateType: undefined, customCertResolver: undefined, serviceName: undefined, domainType: type, + middlewares: [], }, mode: "onChange", }); const certificateType = form.watch("certificateType"); + const useCustomEntrypoint = form.watch("useCustomEntrypoint"); const https = form.watch("https"); const domainType = form.watch("domainType"); const host = form.watch("host"); @@ -220,10 +236,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { internalPath: data?.internalPath || undefined, stripPath: data?.stripPath || false, port: data?.port || undefined, + useCustomEntrypoint: !!data.customEntrypoint, + customEntrypoint: data.customEntrypoint || undefined, certificateType: data?.certificateType || undefined, customCertResolver: data?.customCertResolver || undefined, serviceName: data?.serviceName || undefined, domainType: data?.domainType || type, + middlewares: data?.middlewares || [], }); } @@ -234,13 +253,16 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { internalPath: undefined, stripPath: false, port: undefined, + useCustomEntrypoint: false, + customEntrypoint: undefined, https: false, certificateType: undefined, customCertResolver: undefined, domainType: type, + middlewares: [], }); } - }, [form, data, isLoading, domainId]); + }, [form, data, isPending, domainId]); // Separate effect for handling custom cert resolver validation useEffect(() => { @@ -268,6 +290,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { composeId: id, }), ...data, + customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null, }) .then(async () => { toast.success(dictionary.success); @@ -635,6 +658,55 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { }} /> + ( + +
+ Custom Entrypoint + + Use custom entrypoint for domina +
+ "web" and/or "websecure" is used by default. +
+ +
+ + { + field.onChange(checked); + if (!checked) { + form.setValue("customEntrypoint", undefined); + } + }} + /> + +
+ )} + /> + + {useCustomEntrypoint && ( + ( + + Entrypoint Name + + + + + + )} + /> + )} + { )} )} + ( + +
+ Middlewares + + + +
+ ? +
+
+ +

+ Add Traefik middleware references. Middlewares + must be defined in your Traefik configuration. +

+
+
+
+
+
+ {field.value?.map((name, index) => ( + + {name} + { + const newMiddlewares = [...(field.value || [])]; + newMiddlewares.splice(index, 1); + form.setValue("middlewares", newMiddlewares); + }} + /> + + ))} +
+ +
+ { + if (e.key === "Enter") { + e.preventDefault(); + const input = e.currentTarget; + const value = input.value.trim(); + if (value && !field.value?.includes(value)) { + form.setValue("middlewares", [ + ...(field.value || []), + value, + ]); + input.value = ""; + } + } + }} + /> + +
+
+ +
+ )} + />
- diff --git a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx index 1fd3d82e9..e971f9ab7 100644 --- a/apps/dokploy/components/dashboard/application/domains/show-domains.tsx +++ b/apps/dokploy/components/dashboard/application/domains/show-domains.tsx @@ -1,8 +1,22 @@ +import { + type ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + type SortingState, + useReactTable, + type VisibilityState, +} from "@tanstack/react-table"; import { CheckCircle2, + ChevronDown, ExternalLink, GlobeIcon, InfoIcon, + LayoutGrid, + LayoutList, Loader2, PenBoxIcon, RefreshCw, @@ -23,6 +37,21 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { Tooltip, TooltipContent, @@ -30,6 +59,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { api } from "@/utils/api"; +import { createColumns } from "./columns"; import { DnsHelperModal } from "./dns-helper-modal"; import { AddDomain } from "./handle-domain"; @@ -50,6 +80,9 @@ interface 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 } = type === "application" ? api.application.one.useQuery( @@ -71,6 +104,19 @@ export const ShowDomains = ({ id, type }: Props) => { const [validationStates, setValidationStates] = useState( {}, ); + const [viewMode, setViewMode] = useState<"grid" | "table">(() => { + if (typeof window !== "undefined") { + return ( + (localStorage.getItem("domains-view-mode") as "grid" | "table") ?? + "grid" + ); + } + return "grid"; + }); + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + const [columnVisibility, setColumnVisibility] = useState({}); + const [rowSelection, setRowSelection] = useState({}); const { data: ip } = api.settings.getIp.useQuery(); const { @@ -97,9 +143,19 @@ export const ShowDomains = ({ id, type }: Props) => { const { mutateAsync: validateDomain } = api.domain.validateDomain.useMutation(); - const { mutateAsync: deleteDomain, isLoading: isRemoving } = + const { mutateAsync: deleteDomain, isPending: isRemoving } = api.domain.delete.useMutation(); + const handleDeleteDomain = async (domainId: string) => { + try { + await deleteDomain({ domainId }); + refetch(); + toast.success("Domain deleted successfully"); + } catch { + toast.error("Error deleting domain"); + } + }; + const handleValidateDomain = async (host: string) => { setValidationStates((prev) => ({ ...prev, @@ -137,6 +193,37 @@ export const ShowDomains = ({ id, type }: Props) => { } }; + const columns = createColumns({ + id, + type, + validationStates, + handleValidateDomain, + handleDeleteDomain, + isDeleting: isRemoving, + serverIp: application?.server?.ipAddress?.toString() || ip?.toString(), + canCreateDomain, + canDeleteDomain, + }); + + const table = useReactTable({ + data: data ?? [], + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + }, + }); + return (
@@ -148,13 +235,32 @@ export const ShowDomains = ({ id, type }: Props) => {
-
+
{data && data?.length > 0 && ( - - - + {canCreateDomain && ( + + + + )} + )}
@@ -173,13 +279,131 @@ export const ShowDomains = ({ id, type }: Props) => { To access the application it is required to set at least 1 domain -
- - - + {canCreateDomain && ( +
+ + + +
+ )} +
+ ) : viewMode === "table" ? ( +
+
+ + table.getColumn("host")?.setFilterValue(event.target.value) + } + className="md:max-w-sm" + /> + + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ); + })} + +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table?.getRowModel()?.rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ {data && data?.length > 0 && ( +
+
+ + +
+
+ )}
) : (
@@ -214,47 +438,51 @@ export const ShowDomains = ({ id, type }: Props) => { } /> )} - - - - { - await deleteDomain({ - domainId: item.domainId, - }) - .then((_data) => { - refetch(); - toast.success( - "Domain deleted successfully", - ); + + + )} + {canDeleteDomain && ( + { + await deleteDomain({ + domainId: item.domainId, }) - .catch(() => { - toast.error("Error deleting domain"); - }); - }} - > - - + + + )}
@@ -332,6 +560,22 @@ export const ShowDomains = ({ id, type }: Props) => { )} + {item.middlewares?.map((middleware, index) => ( + + + + + + Middleware: {middleware} + + + +

Traefik middleware reference

+
+
+
+ ))} + diff --git a/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx similarity index 84% rename from apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx rename to apps/dokploy/components/dashboard/application/environment/show-environment.tsx index 797a317a8..86f7a0dff 100644 --- a/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx @@ -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 { type CSSProperties, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -36,16 +36,19 @@ interface Props { } export const ShowEnvironment = ({ id, type }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canWrite = permissions?.envVars.write ?? 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 }), + 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 }), - compose: () => - api.compose.one.useQuery({ composeId: id }, { enabled: !!id }), + mysql: () => api.mysql.one.useQuery({ mysqlId: 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] ? queryMap[type]() @@ -53,14 +56,15 @@ export const ShowEnvironment = ({ id, type }: Props) => { const [isEnvVisible, setIsEnvVisible] = useState(true); const mutationMap = { - postgres: () => api.postgres.update.useMutation(), - redis: () => api.redis.update.useMutation(), - mysql: () => api.mysql.update.useMutation(), + compose: () => api.compose.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), mariadb: () => api.mariadb.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]() : api.mongo.update.useMutation(); @@ -85,12 +89,13 @@ export const ShowEnvironment = ({ id, type }: Props) => { const onSubmit = async (formData: EnvironmentSchema) => { mutateAsync({ + composeId: id || "", + libsqlId: id || "", + mariadbId: id || "", mongoId: id || "", + mysqlId: id || "", postgresId: id || "", redisId: id || "", - mysqlId: id || "", - mariadbId: id || "", - composeId: id || "", env: formData.environment, }) .then(async () => { @@ -111,7 +116,7 @@ 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" && !isLoading) { + if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) { e.preventDefault(); form.handleSubmit(onSubmit)(); } @@ -121,7 +126,7 @@ export const ShowEnvironment = ({ id, type }: Props) => { return () => { document.removeEventListener("keydown", handleKeyDown); }; - }, [form, onSubmit, isLoading]); + }, [form, onSubmit, isPending]); return (
@@ -185,25 +190,27 @@ PORT=3000 )} /> -
- {hasChanges && ( + {canWrite && ( +
+ {hasChanges && ( + + )} - )} - -
+
+ )} diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx index cbbd105df..fcfd81778 100644 --- a/apps/dokploy/components/dashboard/application/environment/show.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -31,7 +31,9 @@ interface 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(); const { data, refetch } = api.application.one.useQuery( @@ -104,7 +106,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { // Add keyboard shortcut for Ctrl+S/Cmd+S useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) { + if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) { e.preventDefault(); form.handleSubmit(onSubmit)(); } @@ -114,7 +116,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { return () => { document.removeEventListener("keydown", handleKeyDown); }; - }, [form, onSubmit, isLoading]); + }, [form, onSubmit, isPending]); return ( @@ -201,27 +203,30 @@ export const ShowEnvironment = ({ applicationId }: Props) => { )} /> )} -
- {hasChanges && ( - + )} + - )} - -
+
+ )} diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx index 01db7febc..a4fab46d9 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx @@ -1,5 +1,5 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react"; import Link from "next/link"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; @@ -74,10 +74,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { api.bitbucket.bitbucketProviders.useQuery(); const { data, refetch } = api.application.one.useQuery({ applicationId }); - const { mutateAsync, isLoading: isSavingBitbucketProvider } = + const { mutateAsync, isPending: isSavingBitbucketProvider } = api.application.saveBitbucketProvider.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { buildPath: "/", repository: { @@ -245,13 +245,13 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -263,11 +263,15 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!bitbucketId ? ( + + Select a Bitbucket account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. @@ -329,7 +333,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { !field.value && "text-muted-foreground", )} > - {status === "loading" && fetchStatus === "fetching" + {status === "pending" && fetchStatus === "fetching" ? "Loading...." : field.value ? branches?.find( @@ -346,7 +350,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { placeholder="Search branch..." className="h-9" /> - {status === "loading" && fetchStatus === "fetching" && ( + {status === "pending" && fetchStatus === "fetching" && ( Loading Branches.... @@ -412,10 +416,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { Watch Paths - -
- ? -
+ +

diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-docker-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-docker-provider.tsx index fcdcf0a93..078271bca 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-docker-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-docker-provider.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-drag-n-drop.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-drag-n-drop.tsx index 00e18c2ab..583b865c5 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-drag-n-drop.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-drag-n-drop.tsx @@ -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 { useEffect } from "react"; import { useForm } from "react-hook-form"; @@ -24,10 +24,10 @@ interface Props { export const SaveDragNDrop = ({ applicationId }: Props) => { const { data, refetch } = api.application.one.useQuery({ applicationId }); - const { mutateAsync, isLoading } = + const { mutateAsync, isPending } = api.application.dropDeployment.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: {}, resolver: zodResolver(uploadFileSchema), }); @@ -129,8 +129,8 @@ export const SaveDragNDrop = ({ applicationId }: Props) => { diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx index e9be3a2f5..37a387bb5 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx @@ -1,5 +1,5 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { KeyRoundIcon, LockIcon, X } from "lucide-react"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/router"; import { useEffect } from "react"; @@ -58,10 +58,10 @@ export const SaveGitProvider = ({ applicationId }: Props) => { const { data: sshKeys } = api.sshKey.all.useQuery(); const router = useRouter(); - const { mutateAsync, isLoading } = + const { mutateAsync, isPending } = api.application.saveGitProvider.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { branch: "", buildPath: "/", @@ -228,10 +228,8 @@ export const SaveGitProvider = ({ applicationId }: Props) => { Watch Paths - -

- ? -
+ +

@@ -317,7 +315,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {

-
diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx index 2198f4a97..02cae2c4a 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx @@ -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 Link from "next/link"; import { useEffect } from "react"; @@ -88,10 +88,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { const { data: giteaProviders } = api.gitea.giteaProviders.useQuery(); const { data, refetch } = api.application.one.useQuery({ applicationId }); - const { mutateAsync, isLoading: isSavingGiteaProvider } = + const { mutateAsync, isPending: isSavingGiteaProvider } = api.application.saveGiteaProvider.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { buildPath: "/", repository: { @@ -258,14 +258,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo: GiteaRepository) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -277,11 +277,15 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!giteaId ? ( + + Select a Gitea account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. @@ -349,7 +353,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { !field.value && "text-muted-foreground", )} > - {status === "loading" && fetchStatus === "fetching" + {status === "pending" && fetchStatus === "fetching" ? "Loading...." : field.value ? branches?.find( @@ -367,7 +371,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { placeholder="Search branch..." className="h-9" /> - {status === "loading" && fetchStatus === "fetching" && ( + {status === "pending" && fetchStatus === "fetching" && ( Loading Branches.... @@ -459,7 +463,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { { - const newPaths = [...field.value]; + const newPaths = [...(field.value || [])]; newPaths.splice(index, 1); field.onChange(newPaths); }} @@ -477,7 +481,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { const input = e.currentTarget; const path = input.value.trim(); if (path) { - field.onChange([...field.value, path]); + field.onChange([...(field.value || []), path]); input.value = ""; } } @@ -494,7 +498,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { ) as HTMLInputElement; const path = input.value.trim(); if (path) { - field.onChange([...field.value, path]); + field.onChange([...(field.value || []), path]); input.value = ""; } }} diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx index 80d6850ca..6bce2d243 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx @@ -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 Link from "next/link"; import { useEffect } from "react"; @@ -72,10 +72,10 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { const { data: githubProviders } = api.github.githubProviders.useQuery(); const { data, refetch } = api.application.one.useQuery({ applicationId }); - const { mutateAsync, isLoading: isSavingGithubProvider } = + const { mutateAsync, isPending: isSavingGithubProvider } = api.application.saveGithubProvider.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { buildPath: "/", repository: { @@ -94,7 +94,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { const githubId = form.watch("githubId"); const triggerType = form.watch("triggerType"); - const { data: repositories, isLoading: isLoadingRepositories } = + const { data: repositories, isPending: isLoadingRepositories } = api.github.getGithubRepositories.useQuery( { githubId, @@ -233,13 +233,13 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -251,11 +251,15 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!githubId ? ( + + Select a GitHub account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. @@ -316,7 +320,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { !field.value && "text-muted-foreground", )} > - {status === "loading" && fetchStatus === "fetching" + {status === "pending" && fetchStatus === "fetching" ? "Loading...." : field.value ? branches?.find( @@ -333,7 +337,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { placeholder="Search branch..." className="h-9" /> - {status === "loading" && fetchStatus === "fetching" && ( + {status === "pending" && fetchStatus === "fetching" && ( Loading Branches.... @@ -455,7 +459,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
{field.value?.map((path, index) => ( diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx index 6197fc49f..b49a1658f 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx @@ -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 Link from "next/link"; import { useEffect, useMemo } from "react"; @@ -74,10 +74,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery(); const { data, refetch } = api.application.one.useQuery({ applicationId }); - const { mutateAsync, isLoading: isSavingGitlabProvider } = + const { mutateAsync, isPending: isSavingGitlabProvider } = api.application.saveGitlabProvider.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { buildPath: "/", repository: { @@ -254,13 +254,13 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -272,11 +272,15 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!gitlabId ? ( + + Select a GitLab account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. @@ -347,7 +351,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { !field.value && "text-muted-foreground", )} > - {status === "loading" && fetchStatus === "fetching" + {status === "pending" && fetchStatus === "fetching" ? "Loading...." : field.value ? branches?.find( @@ -364,7 +368,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { placeholder="Search branch..." className="h-9" /> - {status === "loading" && fetchStatus === "fetching" && ( + {status === "pending" && fetchStatus === "fetching" && ( Loading Branches.... @@ -444,7 +448,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
{field.value?.map((path, index) => ( diff --git a/apps/dokploy/components/dashboard/application/general/generic/show.tsx b/apps/dokploy/components/dashboard/application/general/generic/show.tsx index a60db800c..9a49b204e 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/show.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/show.tsx @@ -36,13 +36,13 @@ interface Props { } export const ShowProviderForm = ({ applicationId }: Props) => { - const { data: githubProviders, isLoading: isLoadingGithub } = + const { data: githubProviders, isPending: isLoadingGithub } = api.github.githubProviders.useQuery(); - const { data: gitlabProviders, isLoading: isLoadingGitlab } = + const { data: gitlabProviders, isPending: isLoadingGitlab } = api.gitlab.gitlabProviders.useQuery(); - const { data: bitbucketProviders, isLoading: isLoadingBitbucket } = + const { data: bitbucketProviders, isPending: isLoadingBitbucket } = api.bitbucket.bitbucketProviders.useQuery(); - const { data: giteaProviders, isLoading: isLoadingGitea } = + const { data: giteaProviders, isPending: isLoadingGitea } = api.gitea.giteaProviders.useQuery(); const { data: application, refetch } = api.application.one.useQuery({ diff --git a/apps/dokploy/components/dashboard/application/general/show.tsx b/apps/dokploy/components/dashboard/application/general/show.tsx index 5387659ad..01fc9e84a 100644 --- a/apps/dokploy/components/dashboard/application/general/show.tsx +++ b/apps/dokploy/components/dashboard/application/general/show.tsx @@ -30,6 +30,9 @@ interface Props { export const ShowGeneralApplication = ({ applicationId }: Props) => { 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( { applicationId, @@ -37,14 +40,14 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => { { enabled: !!applicationId }, ); const { mutateAsync: update } = api.application.update.useMutation(); - const { mutateAsync: start, isLoading: isStarting } = + const { mutateAsync: start, isPending: isStarting } = api.application.start.useMutation(); - const { mutateAsync: stop, isLoading: isStopping } = + const { mutateAsync: stop, isPending: isStopping } = api.application.stop.useMutation(); const { mutateAsync: deploy } = api.application.deploy.useMutation(); - const { mutateAsync: reload, isLoading: isReloading } = + const { mutateAsync: reload, isPending: isReloading } = api.application.reload.useMutation(); const { mutateAsync: redeploy } = api.application.redeploy.useMutation(); @@ -57,128 +60,135 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => { - { - 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`, - ); + {canDeploy && ( + { + await deploy({ + applicationId: applicationId, }) - .catch(() => { - toast.error("Error deploying application"); - }); - }} - > - - - { - await reload({ - applicationId: applicationId, - appName: data?.appName || "", - }) - .then(() => { - toast.success("Application reloaded successfully"); - refetch(); + + + )} + {canDeploy && ( + { + await reload({ + applicationId: applicationId, + appName: data?.appName || "", }) - .catch(() => { - toast.error("Error reloading application"); - }); - }} - > - - - { - await redeploy({ - applicationId: applicationId, - }) - .then(() => { - toast.success("Application rebuilt successfully"); - refetch(); + + + )} + {canDeploy && ( + { + await redeploy({ + applicationId: applicationId, }) - .catch(() => { - toast.error("Error rebuilding application"); - }); - }} - > - - + + + )} - {data?.applicationStatus === "idle" ? ( + {canDeploy && data?.applicationStatus === "idle" ? ( { - ) : ( + ) : canDeploy ? ( { - )} + ) : null} { Open Terminal -
- Autodeploy - { - await update({ - applicationId, - autoDeploy: enabled, - }) - .then(async () => { - toast.success("Auto Deploy Updated"); - await refetch(); + {canUpdateService && ( +
+ Autodeploy + { + await update({ + applicationId, + autoDeploy: enabled, }) - .catch(() => { - toast.error("Error updating Auto Deploy"); - }); - }} - className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" - /> -
+ .then(async () => { + toast.success("Auto Deploy Updated"); + await refetch(); + }) + .catch(() => { + toast.error("Error updating Auto Deploy"); + }); + }} + className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" + /> +
+ )} -
- Clean Cache - { - await update({ - applicationId, - cleanCache: enabled, - }) - .then(async () => { - toast.success("Clean Cache Updated"); - await refetch(); + {canUpdateService && ( +
+ Clean Cache + { + await update({ + applicationId, + cleanCache: enabled, }) - .catch(() => { - toast.error("Error updating Clean Cache"); - }); - }} - className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" - /> -
+ .then(async () => { + toast.success("Clean Cache Updated"); + await refetch(); + }) + .catch(() => { + toast.error("Error updating Clean Cache"); + }); + }} + className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" + /> +
+ )}
diff --git a/apps/dokploy/components/dashboard/application/logs/show.tsx b/apps/dokploy/components/dashboard/application/logs/show.tsx index e5dff075e..06b257766 100644 --- a/apps/dokploy/components/dashboard/application/logs/show.tsx +++ b/apps/dokploy/components/dashboard/application/logs/show.tsx @@ -34,6 +34,7 @@ export const DockerLogs = dynamic( export const badgeStateColor = (state: string) => { switch (state) { case "running": + case "ready": return "green"; case "exited": case "shutdown": @@ -55,7 +56,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { const [containerId, setContainerId] = useState(); const [option, setOption] = useState<"swarm" | "native">("native"); - const { data: services, isLoading: servicesLoading } = + const { data: services, isPending: servicesLoading } = api.docker.getServiceContainersByAppName.useQuery( { appName, @@ -66,7 +67,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { }, ); - const { data: containers, isLoading: containersLoading } = + const { data: containers, isPending: containersLoading } = api.docker.getContainersByAppNameMatch.useQuery( { appName, @@ -90,7 +91,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { }, [option, services, containers]); const isLoading = option === "native" ? containersLoading : servicesLoading; - const containersLenght = + const containersLength = option === "native" ? containers?.length : services?.length; return ( @@ -142,6 +143,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { {container.state} + {container.status ? ` ${container.status}` : ""} ))}
@@ -157,15 +159,25 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { {container.state} + {container.currentState + ? ` ${container.currentState}` + : ""} ))} )} - Containers ({containersLenght}) + Containers ({containersLength}) + {option === "swarm" && + services?.find((c) => c.containerId === containerId)?.error && ( +
+ Error: + {services?.find((c) => c.containerId === containerId)?.error} +
+ )} void; + onOpenChange: (open: boolean) => void; + alwaysVisible?: boolean; +} + +export const CreateFileDialog = ({ + folderPath, + onCreate, + onOpenChange, + alwaysVisible = false, +}: Props) => { + const [filename, setFilename] = useState(""); + const [content, setContent] = useState(""); + + const handleCreate = () => { + if (!filename.trim()) return; + onCreate(filename.trim(), content); + setFilename(""); + setContent(""); + onOpenChange(false); + }; + + return ( + + + + + +
{ + e.preventDefault(); + handleCreate(); + }} + > + + Create file + + {folderPath ? `New file in ${folderPath}/` : "New file in root"} + + +
+
+ + setFilename(e.target.value)} + /> +
+
+ +
+ setContent(v ?? "")} + className="h-full" + wrapperClassName="h-[200px]" + lineWrapping + /> +
+
+
+ + + + + + + + +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/patches/edit-patch-dialog.tsx b/apps/dokploy/components/dashboard/application/patches/edit-patch-dialog.tsx new file mode 100644 index 000000000..8c5a42836 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/patches/edit-patch-dialog.tsx @@ -0,0 +1,102 @@ +import { Loader2, Pencil } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { api } from "@/utils/api"; + +interface Props { + patchId: string; + entityId: string; + type: "application" | "compose"; + onSuccess?: () => void; +} + +export const EditPatchDialog = ({ + patchId, + entityId, + type, + onSuccess, +}: Props) => { + const { data: patch, isPending: isPatchLoading } = api.patch.one.useQuery( + { patchId }, + { enabled: !!patchId }, + ); + const [content, setContent] = useState(""); + + useEffect(() => { + if (patch) { + setContent(patch.content); + } + }, [patch]); + + const utils = api.useUtils(); + const updatePatch = api.patch.update.useMutation(); + + const handleSave = () => { + updatePatch + .mutateAsync({ patchId, content }) + .then(() => { + toast.success("Patch saved"); + utils.patch.byEntityId.invalidate({ id: entityId, type }); + onSuccess?.(); + }) + .catch((err) => { + toast.error(err.message); + }); + }; + + return ( + + + + + + + Edit Patch + + {patch ? `Editing: ${patch.filePath}` : "Loading patch..."} + + + {isPatchLoading ? ( +
+ +
+ ) : ( +
+ setContent(value ?? "")} + className="h-[400px] w-full" + wrapperClassName="h-[400px]" + lineWrapping + /> +
+ )} + + + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/patches/index.ts b/apps/dokploy/components/dashboard/application/patches/index.ts new file mode 100644 index 000000000..053e644b7 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/patches/index.ts @@ -0,0 +1,2 @@ +export * from "./patch-editor"; +export * from "./show-patches"; diff --git a/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx b/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx new file mode 100644 index 000000000..4b212b004 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/patches/patch-editor.tsx @@ -0,0 +1,368 @@ +import { + ArrowLeft, + ChevronRight, + File, + Folder, + Loader2, + Save, + Trash2, +} from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { CodeEditor } from "@/components/shared/code-editor"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { api } from "@/utils/api"; +import { CreateFileDialog } from "./create-file-dialog"; + +interface Props { + id: string; + type: "application" | "compose"; + repoPath: string; + onClose: () => void; +} + +type DirectoryEntry = { + name: string; + path: string; + type: "file" | "directory"; + children?: DirectoryEntry[]; +}; + +export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => { + const [selectedFile, setSelectedFile] = useState(null); + const [fileContent, setFileContent] = useState(""); + const [createFolderPath, setCreateFolderPath] = useState(null); + const [expandedFolders, setExpandedFolders] = useState>( + new Set(), + ); + + const utils = api.useUtils(); + const { data: directories, isPending: isDirLoading } = + api.patch.readRepoDirectories.useQuery( + { id: id, type, repoPath }, + { enabled: !!repoPath }, + ); + + const { data: patches } = api.patch.byEntityId.useQuery( + { id, type }, + { enabled: !!id }, + ); + + const { mutateAsync: saveAsPatch, isPending: isSavingPatch } = + api.patch.saveFileAsPatch.useMutation(); + + const { mutateAsync: markForDeletion, isPending: isMarkingDeletion } = + api.patch.markFileForDeletion.useMutation(); + + const updatePatch = api.patch.update.useMutation(); + + const { data: fileData, isFetching: isFileLoading } = + api.patch.readRepoFile.useQuery( + { + id, + type, + filePath: selectedFile || "", + }, + { + enabled: !!selectedFile, + }, + ); + + useEffect(() => { + if (fileData !== undefined) { + setFileContent(fileData); + } + }, [fileData]); + + const handleFileSelect = (filePath: string) => { + setSelectedFile(filePath); + }; + + const toggleFolder = (path: string) => { + setExpandedFolders((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }; + + const handleSave = () => { + if (!selectedFile) return; + saveAsPatch({ + id, + type, + filePath: selectedFile, + content: fileContent, + patchType: "update", + }) + .then(() => { + toast.success("Patch saved"); + utils.patch.byEntityId.invalidate({ id, type }); + }) + .catch(() => { + toast.error("Failed to save patch"); + }); + }; + + const handleMarkForDeletion = () => { + if (!selectedFile) return; + markForDeletion({ id, type, filePath: selectedFile }) + .then(() => { + toast.success("File marked for deletion"); + utils.patch.byEntityId.invalidate({ id, type }); + }) + .catch(() => { + toast.error("Failed to mark file for deletion"); + }); + }; + + const handleCreateFile = useCallback( + (folderPath: string, filename: string, content: string) => { + const filePath = folderPath ? `${folderPath}/${filename}` : filename; + saveAsPatch({ + id, + type, + filePath, + content, + patchType: "create", + }) + .then(() => { + toast.success("File created"); + utils.patch.byEntityId.invalidate({ id, type }); + }) + .catch(() => { + toast.error("Failed to create file"); + }); + }, + [id, type, saveAsPatch, utils], + ); + + const selectedFilePatch = patches?.find( + (p) => p.filePath === selectedFile && p.type === "delete", + ); + + const handleUnmarkDeletion = () => { + if (!selectedFilePatch) return; + updatePatch + .mutateAsync({ + patchId: selectedFilePatch.patchId, + type: "update", + content: fileData || "", + }) + .then(() => { + toast.success("Deletion unmarked"); + utils.patch.byEntityId.invalidate({ id, type }); + }) + .catch(() => { + toast.error("Failed to unmark deletion"); + }); + }; + + const hasChanges = fileData !== undefined && fileContent !== fileData; + + const renderTree = useCallback( + (entries: DirectoryEntry[], depth = 0) => { + return entries + .sort((a, b) => { + // Directories first, then alphabetically + if (a.type !== b.type) { + return a.type === "directory" ? -1 : 1; + } + return a.name.localeCompare(b.name); + }) + .map((entry) => { + const isExpanded = expandedFolders.has(entry.path); + const isSelected = selectedFile === entry.path; + + if (entry.type === "directory") { + return ( +
+
+ + + handleCreateFile(entry.path, filename, content) + } + onOpenChange={(open) => + setCreateFolderPath(open ? entry.path : null) + } + /> +
+ {isExpanded && entry.children && ( +
{renderTree(entry.children, depth + 1)}
+ )} +
+ ); + } + + const isMarkedForDeletion = patches?.some( + (p) => p.filePath === entry.path && p.type === "delete", + ); + + return ( + + ); + }); + }, + [expandedFolders, selectedFile, patches, handleCreateFile], + ); + + return ( + + +
+ +
+ Edit File + + {selectedFile + ? `Editing: ${selectedFile}` + : "Select a file from the tree to edit"} + +
+
+ {selectedFile && ( +
+ {selectedFilePatch ? ( + + ) : ( + <> + + + + )} +
+ )} +
+ +
+
+ +
+
+ + handleCreateFile("", filename, content) + } + onOpenChange={(open) => + setCreateFolderPath(open ? "" : null) + } + /> + + New file in root + +
+ {isDirLoading ? ( +
+ +
+ ) : directories ? ( + renderTree(directories) + ) : ( +
+ No files found +
+ )} +
+
+
+
+ {isFileLoading ? ( +
+ +
+ ) : selectedFile ? ( + setFileContent(value || "")} + className="h-full w-full" + wrapperClassName="h-full" + lineWrapping + /> + ) : ( +
+ Select a file to edit +
+ )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/patches/show-patches.tsx b/apps/dokploy/components/dashboard/application/patches/show-patches.tsx new file mode 100644 index 000000000..e471b3fc1 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/patches/show-patches.tsx @@ -0,0 +1,225 @@ +import { File, FilePlus2, Loader2, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/utils/api"; +import { EditPatchDialog } from "./edit-patch-dialog"; +import { PatchEditor } from "./patch-editor"; + +interface Props { + id: string; + type: "application" | "compose"; +} + +export const ShowPatches = ({ id, type }: Props) => { + const [selectedFile, setSelectedFile] = useState(null); + const [repoPath, setRepoPath] = useState(null); + const [isLoadingRepo, setIsLoadingRepo] = useState(false); + + const utils = api.useUtils(); + + const { data: patches, isPending: isPatchesLoading } = + api.patch.byEntityId.useQuery({ id, type }, { enabled: !!id }); + + const mutationMap = { + application: () => api.patch.delete.useMutation(), + compose: () => api.patch.delete.useMutation(), + }; + + const ensureRepo = api.patch.ensureRepo.useMutation(); + + const togglePatch = api.patch.toggleEnabled.useMutation(); + + const { mutateAsync } = mutationMap[type] + ? mutationMap[type]() + : api.patch.delete.useMutation(); + + const handleCloseEditor = () => { + setSelectedFile(null); + setRepoPath(null); + }; + + if (repoPath) { + return ( + + ); + } + + const handleOpenEditor = async () => { + setIsLoadingRepo(true); + await ensureRepo + .mutateAsync({ id, type }) + .then((result) => { + setRepoPath(result); + }) + .catch((err) => { + toast.error(err.message); + }) + .finally(() => { + setIsLoadingRepo(false); + }); + }; + + return ( + + +
+ Patches + + Apply code patches to your repository during build. Patches are + applied after cloning the repository and before building. + +
+ {patches && patches?.length > 0 && ( + + )} +
+ + {isPatchesLoading ? ( +
+ +
+ ) : patches?.length === 0 ? ( +
+
+ +
+
+

No patches yet

+

+ Add file patches to modify your repo before each build—configs, + env, or code. Create your first patch to get started. +

+
+ +
+ ) : ( + + + + File Path + Type + Enabled + Actions + + + + {patches?.map((patch) => ( + + +
+ + {patch.filePath} +
+
+ + + {patch.type} + + + + { + togglePatch + .mutateAsync({ + patchId: patch.patchId, + enabled: checked, + }) + .then(() => { + toast.success("Patch updated"); + utils.patch.byEntityId.invalidate({ + id, + type, + }); + }) + .catch((err) => { + toast.error(err.message); + }) + .finally(() => { + setIsLoadingRepo(false); + }); + }} + /> + + +
+ {(patch.type === "update" || patch.type === "create") && ( + + )} + +
+
+
+ ))} +
+
+ )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx index bb9321a51..72815fd8f 100644 --- a/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx +++ b/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { Dices } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -75,11 +75,11 @@ export const AddPreviewDomain = ({ }, ); - const { mutateAsync, isError, error, isLoading } = domainId + const { mutateAsync, isError, error, isPending } = domainId ? api.domain.update.useMutation() : api.domain.create.useMutation(); - const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } = + const { mutateAsync: generateDomain, isPending: isLoadingGenerate } = api.domain.generateDomain.useMutation(); const form = useForm({ @@ -103,7 +103,7 @@ export const AddPreviewDomain = ({ if (!domainId) { form.reset({}); } - }, [form, form.reset, data, isLoading]); + }, [form, form.reset, data, isPending]); const dictionary = { success: domainId ? "Domain Updated" : "Domain Created", @@ -301,7 +301,7 @@ export const AddPreviewDomain = ({ - diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx index 6cf8d8830..e12400a7c 100644 --- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx @@ -43,7 +43,7 @@ interface Props { export const ShowPreviewDeployments = ({ applicationId }: Props) => { const { data } = api.application.one.useQuery({ applicationId }); - const { mutateAsync: deletePreviewDeployment, isLoading } = + const { mutateAsync: deletePreviewDeployment, isPending } = api.previewDeployment.delete.useMutation(); const { mutateAsync: redeployPreviewDeployment } = @@ -57,8 +57,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => { { applicationId }, { enabled: !!applicationId, - refetchInterval: (data) => - data?.some((d) => d.previewStatus === "running") ? 2000 : false, + refetchInterval: 2000, }, ); @@ -282,7 +281,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => { diff --git a/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx index e85b1b004..36ddb53f1 100644 --- a/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx +++ b/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; import { CheckIcon, ChevronsUpDown, @@ -220,8 +220,8 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => { const [isOpen, setIsOpen] = useState(false); const [cacheType, setCacheType] = useState("cache"); const utils = api.useUtils(); - const form = useForm>({ - resolver: zodResolver(formSchema), + const form = useForm({ + resolver: standardSchemaResolver(formSchema), defaultValues: { name: "", cronExpression: "", @@ -275,11 +275,11 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => { } }, [form, schedule, scheduleId]); - const { mutateAsync, isLoading } = scheduleId + const { mutateAsync, isPending } = scheduleId ? api.schedule.update.useMutation() : api.schedule.create.useMutation(); - const onSubmit = async (values: z.infer) => { + const onSubmit = async (values: z.output) => { if (!id && !scheduleId) return; await mutateAsync({ @@ -662,7 +662,7 @@ echo "Hello, world!" )} /> - diff --git a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx index 26bfa9421..a9550fda2 100644 --- a/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx +++ b/apps/dokploy/components/dashboard/application/schedules/show-schedules.tsx @@ -51,7 +51,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { }, ); const utils = api.useUtils(); - const { mutateAsync: deleteSchedule, isLoading: isDeleting } = + const { mutateAsync: deleteSchedule, isPending: isDeleting } = api.schedule.delete.useMutation(); const { mutateAsync: runManually } = api.schedule.runManually.useMutation(); diff --git a/apps/dokploy/components/dashboard/application/update-application.tsx b/apps/dokploy/components/dashboard/application/update-application.tsx index 754074d75..98c49a999 100644 --- a/apps/dokploy/components/dashboard/application/update-application.tsx +++ b/apps/dokploy/components/dashboard/application/update-application.tsx @@ -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 { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -43,7 +43,7 @@ interface Props { export const UpdateApplication = ({ applicationId }: Props) => { const [isOpen, setIsOpen] = useState(false); const utils = api.useUtils(); - const { mutateAsync, error, isError, isLoading } = + const { mutateAsync, error, isError, isPending } = api.application.update.useMutation(); const { data } = api.application.one.useQuery( { @@ -148,7 +148,7 @@ export const UpdateApplication = ({ applicationId }: Props) => { /> diff --git a/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx index 6eda33648..684620947 100644 --- a/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx +++ b/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx @@ -1,6 +1,6 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import copy from "copy-to-clipboard"; -import { debounce } from "lodash"; +import debounce from "lodash/debounce"; import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -53,27 +53,15 @@ interface Props { } const RestoreBackupSchema = z.object({ - destinationId: z - .string({ - required_error: "Please select a destination", - }) - .min(1, { - message: "Destination is required", - }), - backupFile: z - .string({ - required_error: "Please select a backup file", - }) - .min(1, { - message: "Backup file is required", - }), - volumeName: z - .string({ - required_error: "Please enter a volume name", - }) - .min(1, { - message: "Volume name is required", - }), + destinationId: z.string().min(1, { + message: "Destination is required", + }), + backupFile: z.string().min(1, { + message: "Backup file is required", + }), + volumeName: z.string().min(1, { + message: "Volume name is required", + }), }); export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => { @@ -83,7 +71,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => { const { data: destinations = [] } = api.destination.all.useQuery(); - const form = useForm>({ + const form = useForm({ defaultValues: { destinationId: "", backupFile: "", @@ -105,7 +93,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => { debouncedSetSearch(value); }; - const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery( + const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery( { destinationId: destinationId, search: debouncedSearchTerm, @@ -294,7 +282,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => { onValueChange={handleSearchChange} className="h-9" /> - {isLoading ? ( + {isPending ? (
Loading backup files...
diff --git a/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx index 2e4dac472..526bcfa77 100644 --- a/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx +++ b/apps/dokploy/components/dashboard/application/volume-backups/show-volume-backups.tsx @@ -54,7 +54,7 @@ export const ShowVolumeBackups = ({ }, ); const utils = api.useUtils(); - const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } = + const { mutateAsync: deleteVolumeBackup, isPending: isDeleting } = api.volumeBackups.delete.useMutation(); const { mutateAsync: runManually } = api.volumeBackups.runManually.useMutation(); diff --git a/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx b/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx index 52eb18907..c5f9334ec 100644 --- a/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx +++ b/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -52,7 +52,7 @@ export const AddCommandCompose = ({ composeId }: Props) => { const utils = api.useUtils(); - const { mutateAsync, isLoading } = api.compose.update.useMutation(); + const { mutateAsync, isPending } = api.compose.update.useMutation(); const form = useForm({ defaultValues: { @@ -128,7 +128,7 @@ export const AddCommandCompose = ({ composeId }: Props) => { />
-
diff --git a/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx b/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx index 5b6e04154..0fad7d20e 100644 --- a/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx +++ b/apps/dokploy/components/dashboard/compose/advanced/add-isolation.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { AlertTriangle, Loader2 } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; diff --git a/apps/dokploy/components/dashboard/compose/delete-service.tsx b/apps/dokploy/components/dashboard/compose/delete-service.tsx index 5c8577dff..35fe01ff9 100644 --- a/apps/dokploy/components/dashboard/compose/delete-service.tsx +++ b/apps/dokploy/components/dashboard/compose/delete-service.tsx @@ -1,5 +1,5 @@ import type { ServiceType } from "@dokploy/server/db/schema"; -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import copy from "copy-to-clipboard"; import { Copy, Trash2 } from "lucide-react"; import { useRouter } from "next/router"; @@ -46,6 +46,8 @@ interface Props { } export const DeleteService = ({ id, type }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canDelete = permissions?.service.delete ?? false; const [isOpen, setIsOpen] = useState(false); const queryMap = { @@ -55,6 +57,7 @@ export const DeleteService = ({ id, type }: Props) => { mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), mariadb: () => api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), + libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), application: () => api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), @@ -70,11 +73,12 @@ export const DeleteService = ({ id, type }: Props) => { redis: () => api.redis.remove.useMutation(), mysql: () => api.mysql.remove.useMutation(), mariadb: () => api.mariadb.remove.useMutation(), + libsql: () => api.libsql.remove.useMutation(), application: () => api.application.delete.useMutation(), mongo: () => api.mongo.remove.useMutation(), compose: () => api.compose.delete.useMutation(), }; - const { mutateAsync, isLoading } = mutationMap[type] + const { mutateAsync, isPending } = mutationMap[type] ? mutationMap[type]() : api.mongo.remove.useMutation(); const { push } = useRouter(); @@ -96,6 +100,7 @@ export const DeleteService = ({ id, type }: Props) => { redisId: id || "", mysqlId: id || "", mariadbId: id || "", + libsqlId: id || "", applicationId: id || "", composeId: id || "", deleteVolumes, @@ -123,6 +128,8 @@ export const DeleteService = ({ id, type }: Props) => { data?.applicationStatus === "running") || (data && "composeStatus" in data && data?.composeStatus === "running"); + if (!canDelete) return null; + return ( @@ -130,7 +137,7 @@ export const DeleteService = ({ id, type }: Props) => { variant="ghost" size="icon" className="group hover:bg-red-500/10 " - isLoading={isLoading} + isLoading={isPending} > @@ -228,7 +235,7 @@ export const DeleteService = ({ id, type }: Props) => { - - { - await redeploy({ - composeId: composeId, - }) - .then(() => { - toast.success("Compose reloaded successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error reloading compose"); - }); - }} - > - - - {data?.composeType === "docker-compose" && - data?.composeStatus === "idle" ? ( + {canDeploy && ( { - await start({ + await deploy({ composeId: composeId, }) .then(() => { - toast.success("Compose started successfully"); + toast.success("Compose deployed successfully"); refetch(); + router.push( + `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`, + ); }) .catch(() => { - toast.error("Error starting compose"); + toast.error("Error deploying compose"); }); }} > - ) : ( + )} + {canDeploy && ( { - await stop({ + await redeploy({ composeId: composeId, }) .then(() => { - toast.success("Compose stopped successfully"); + toast.success("Compose reloaded successfully"); refetch(); }) .catch(() => { - toast.error("Error stopping compose"); + toast.error("Error reloading compose"); }); }} > )} + {canDeploy && + (data?.composeType === "docker-compose" && + data?.composeStatus === "idle" ? ( + { + await start({ + composeId: composeId, + }) + .then(() => { + toast.success("Compose started successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error starting compose"); + }); + }} + > + + + ) : ( + { + await stop({ + composeId: composeId, + }) + .then(() => { + toast.success("Compose stopped successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error stopping compose"); + }); + }} + > + + + ))} { Open Terminal -
- Autodeploy - { - await update({ - composeId, - autoDeploy: enabled, - }) - .then(async () => { - toast.success("Auto Deploy Updated"); - await refetch(); + {canUpdateService && ( +
+ Autodeploy + { + await update({ + composeId, + autoDeploy: enabled, }) - .catch(() => { - toast.error("Error updating Auto Deploy"); - }); - }} - className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" - /> -
+ .then(async () => { + toast.success("Auto Deploy Updated"); + await refetch(); + }) + .catch(() => { + toast.error("Error updating Auto Deploy"); + }); + }} + className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" + /> +
+ )}
); }; diff --git a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx index cb727e2a9..28f958e3e 100644 --- a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx +++ b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx @@ -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 { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -26,6 +26,8 @@ const AddComposeFile = z.object({ type AddComposeFile = z.infer; export const ComposeFileEditor = ({ composeId }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canUpdate = permissions?.service.create ?? false; const utils = api.useUtils(); const { data, refetch } = api.compose.one.useQuery( { @@ -34,7 +36,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => { { enabled: !!composeId }, ); - const { mutateAsync, isLoading } = api.compose.update.useMutation(); + const { mutateAsync, isPending } = api.compose.update.useMutation(); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const form = useForm({ @@ -93,7 +95,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => { // Add keyboard shortcut for Ctrl+S/Cmd+S useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) { + if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) { e.preventDefault(); form.handleSubmit(onSubmit)(); } @@ -103,7 +105,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => { return () => { document.removeEventListener("keydown", handleKeyDown); }; - }, [form, onSubmit, isLoading]); + }, [form, onSubmit, isPending]); return ( <> @@ -164,14 +166,16 @@ services:
- + {canUpdate && ( + + )}
diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx index c89b9893e..3e099251e 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; import Link from "next/link"; import { useEffect } from "react"; @@ -74,10 +74,10 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { api.bitbucket.bitbucketProviders.useQuery(); const { data, refetch } = api.compose.one.useQuery({ composeId }); - const { mutateAsync, isLoading: isSavingBitbucketProvider } = + const { mutateAsync, isPending: isSavingBitbucketProvider } = api.compose.update.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { composePath: "./docker-compose.yml", repository: { @@ -247,13 +247,13 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -265,11 +265,15 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!bitbucketId ? ( + + Select a Bitbucket account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. @@ -331,7 +335,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { !field.value && "text-muted-foreground", )} > - {status === "loading" && fetchStatus === "fetching" + {status === "pending" && fetchStatus === "fetching" ? "Loading...." : field.value ? branches?.find( @@ -348,7 +352,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { placeholder="Search branch..." className="h-9" /> - {status === "loading" && fetchStatus === "fetching" && ( + {status === "pending" && fetchStatus === "fetching" && ( Loading Branches.... diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx index d8c9d4d8f..c84a55bb3 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx @@ -1,5 +1,5 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { KeyRoundIcon, LockIcon, X } from "lucide-react"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/router"; import { useEffect } from "react"; @@ -58,9 +58,9 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => { const { data: sshKeys } = api.sshKey.all.useQuery(); const router = useRouter(); - const { mutateAsync, isLoading } = api.compose.update.useMutation(); + const { mutateAsync, isPending } = api.compose.update.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { branch: "", repositoryURL: "", @@ -230,10 +230,8 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => { Watch Paths - -
- ? -
+ +

@@ -318,7 +316,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {

-
diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx index fce562285..39f025438 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react"; import Link from "next/link"; import { useEffect } from "react"; @@ -72,10 +72,10 @@ interface Props { export const SaveGiteaProviderCompose = ({ composeId }: Props) => { const { data: giteaProviders } = api.gitea.giteaProviders.useQuery(); const { data, refetch } = api.compose.one.useQuery({ composeId }); - const { mutateAsync, isLoading: isSavingGiteaProvider } = + const { mutateAsync, isPending: isSavingGiteaProvider } = api.compose.update.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { composePath: "./docker-compose.yml", repository: { @@ -244,13 +244,13 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")}
@@ -261,11 +261,15 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!giteaId ? ( + + Select a Gitea account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. @@ -327,7 +331,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { !field.value && "text-muted-foreground", )} > - {status === "loading" && fetchStatus === "fetching" + {status === "pending" && fetchStatus === "fetching" ? "Loading...." : field.value ? branches?.find( diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx index 5ad950e4c..827ce1a8a 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react"; import Link from "next/link"; import { useEffect } from "react"; @@ -72,10 +72,10 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { const { data: githubProviders } = api.github.githubProviders.useQuery(); const { data, refetch } = api.compose.one.useQuery({ composeId }); - const { mutateAsync, isLoading: isSavingGithubProvider } = + const { mutateAsync, isPending: isSavingGithubProvider } = api.compose.update.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { composePath: "./docker-compose.yml", repository: { @@ -94,7 +94,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { const repository = form.watch("repository"); const githubId = form.watch("githubId"); const triggerType = form.watch("triggerType"); - const { data: repositories, isLoading: isLoadingRepositories } = + const { data: repositories, isPending: isLoadingRepositories } = api.github.getGithubRepositories.useQuery( { githubId, @@ -234,13 +234,13 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -252,11 +252,15 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!githubId ? ( + + Select a GitHub account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. @@ -317,7 +321,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { !field.value && "text-muted-foreground", )} > - {status === "loading" && fetchStatus === "fetching" + {status === "pending" && fetchStatus === "fetching" ? "Loading...." : field.value ? branches?.find( @@ -334,7 +338,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { placeholder="Search branch..." className="h-9" /> - {status === "loading" && fetchStatus === "fetching" && ( + {status === "pending" && fetchStatus === "fetching" && ( Loading Branches.... diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx index 98c2afa11..63de87d8f 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; import Link from "next/link"; import { useEffect, useMemo } from "react"; @@ -74,10 +74,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery(); const { data, refetch } = api.compose.one.useQuery({ composeId }); - const { mutateAsync, isLoading: isSavingGitlabProvider } = + const { mutateAsync, isPending: isSavingGitlabProvider } = api.compose.update.useMutation(); - const form = useForm({ + const form = useForm({ defaultValues: { composePath: "./docker-compose.yml", repository: { @@ -256,13 +256,13 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { !field.value && "text-muted-foreground", )} > - {isLoadingRepositories - ? "Loading...." - : field.value.owner - ? repositories?.find( + {!field.value.owner + ? "Select repository" + : isLoadingRepositories + ? "Loading...." + : (repositories?.find( (repo) => repo.name === field.value.repo, - )?.name - : "Select repository"} + )?.name ?? "Select repository")} @@ -274,11 +274,15 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { placeholder="Search repository..." className="h-9" /> - {isLoadingRepositories && ( + {!gitlabId ? ( + + Select a GitLab account first + + ) : isLoadingRepositories ? ( Loading Repositories.... - )} + ) : null} No repositories found. @@ -349,7 +353,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { !field.value && "text-muted-foreground", )} > - {status === "loading" && fetchStatus === "fetching" + {status === "pending" && fetchStatus === "fetching" ? "Loading...." : field.value ? branches?.find( @@ -366,7 +370,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { placeholder="Search branch..." className="h-9" /> - {status === "loading" && fetchStatus === "fetching" && ( + {status === "pending" && fetchStatus === "fetching" && ( Loading Branches.... diff --git a/apps/dokploy/components/dashboard/compose/general/generic/show.tsx b/apps/dokploy/components/dashboard/compose/general/generic/show.tsx index 798f72249..759fe728c 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/show.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/show.tsx @@ -27,13 +27,13 @@ interface Props { } export const ShowProviderFormCompose = ({ composeId }: Props) => { - const { data: githubProviders, isLoading: isLoadingGithub } = + const { data: githubProviders, isPending: isLoadingGithub } = api.github.githubProviders.useQuery(); - const { data: gitlabProviders, isLoading: isLoadingGitlab } = + const { data: gitlabProviders, isPending: isLoadingGitlab } = api.gitlab.gitlabProviders.useQuery(); - const { data: bitbucketProviders, isLoading: isLoadingBitbucket } = + const { data: bitbucketProviders, isPending: isLoadingBitbucket } = api.bitbucket.bitbucketProviders.useQuery(); - const { data: giteaProviders, isLoading: isLoadingGitea } = + const { data: giteaProviders, isPending: isLoadingGitea } = api.gitea.giteaProviders.useQuery(); const { mutateAsync: disconnectGitProvider } = diff --git a/apps/dokploy/components/dashboard/compose/general/randomize-compose.tsx b/apps/dokploy/components/dashboard/compose/general/randomize-compose.tsx index 2c488aefe..99c749c26 100644 --- a/apps/dokploy/components/dashboard/compose/general/randomize-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/randomize-compose.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { AlertTriangle } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; diff --git a/apps/dokploy/components/dashboard/compose/general/show-converted-compose.tsx b/apps/dokploy/components/dashboard/compose/general/show-converted-compose.tsx index fac6c2a34..211f5f5c7 100644 --- a/apps/dokploy/components/dashboard/compose/general/show-converted-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/show-converted-compose.tsx @@ -32,7 +32,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => { }, ); - const { mutateAsync, isLoading } = api.compose.fetchSourceType.useMutation(); + const { mutateAsync, isPending } = api.compose.fetchSourceType.useMutation(); useEffect(() => { if (isOpen) { @@ -66,7 +66,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => { Preview your docker-compose file with added domains. Note: At least one domain must be specified for this conversion to take effect. - {isLoading ? ( + {isPending ? (
@@ -82,7 +82,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
@@ -143,15 +144,25 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => { {container.state} + {container.currentState + ? ` ${container.currentState}` + : ""} ))} )} - Containers ({containersLenght}) + Containers ({containersLength}) + {option === "swarm" && + services?.find((c) => c.containerId === containerId)?.error && ( +
+ Error: + {services.find((c) => c.containerId === containerId)?.error} +
+ )} { - const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery( + const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery( { appName, appType, @@ -73,7 +73,7 @@ export const ShowDockerLogsCompose = ({ @@ -613,6 +633,7 @@ export const HandleBackup = ({ type="number" placeholder={"keeps all the backups if left empty"} {...field} + value={field.value as string} />
diff --git a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx index 01f6944e1..7b212acb9 100644 --- a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx +++ b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx @@ -1,6 +1,6 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import copy from "copy-to-clipboard"; -import _ from "lodash"; +import debounce from "lodash/debounce"; import { CheckIcon, ChevronsUpDown, @@ -78,29 +78,17 @@ interface Props { const RestoreBackupSchema = z .object({ - destinationId: z - .string({ - required_error: "Please select a destination", - }) - .min(1, { - message: "Destination is required", - }), - backupFile: z - .string({ - required_error: "Please select a backup file", - }) - .min(1, { - message: "Backup file is required", - }), - databaseName: z - .string({ - required_error: "Please enter a database name", - }) - .min(1, { - message: "Database name is required", - }), + destinationId: z.string().min(1, { + message: "Destination is required", + }), + backupFile: z.string().min(1, { + message: "Backup file is required", + }), + databaseName: z.string().min(1, { + message: "Database name is required", + }), databaseType: z - .enum(["postgres", "mariadb", "mysql", "mongo", "web-server"]) + .enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"]) .optional(), backupType: z.enum(["database", "compose"]).default("database"), metadata: z @@ -219,11 +207,16 @@ export const RestoreBackup = ({ const { data: destinations = [] } = api.destination.all.useQuery(); - const form = useForm>({ + const form = useForm({ defaultValues: { destinationId: "", backupFile: "", - databaseName: databaseType === "web-server" ? "dokploy" : "", + databaseName: + databaseType === "web-server" + ? "dokploy" + : databaseType === "libsql" + ? "iku.db" + : "", databaseType: backupType === "compose" ? ("postgres" as DatabaseType) : databaseType, backupType: backupType, @@ -232,11 +225,11 @@ export const RestoreBackup = ({ resolver: zodResolver(RestoreBackupSchema), }); - const destionationId = form.watch("destinationId"); + const destinationId = form.watch("destinationId"); const currentDatabaseType = form.watch("databaseType"); const metadata = form.watch("metadata"); - const debouncedSetSearch = _.debounce((value: string) => { + const debouncedSetSearch = debounce((value: string) => { setDebouncedSearchTerm(value); }, 350); @@ -245,14 +238,14 @@ export const RestoreBackup = ({ debouncedSetSearch(value); }; - const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery( + const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery( { - destinationId: destionationId, + destinationId: destinationId, search: debouncedSearchTerm, serverId: serverId ?? "", }, { - enabled: isOpen && !!destionationId, + enabled: isOpen && !!destinationId, }, ); @@ -454,7 +447,7 @@ export const RestoreBackup = ({ onValueChange={handleSearchChange} className="h-9" /> - {isLoading ? ( + {isPending ? (
Loading backup files...
@@ -535,7 +528,10 @@ export const RestoreBackup = ({ diff --git a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx index 55a09b25f..ebffaccb3 100644 --- a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx +++ b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx @@ -53,14 +53,16 @@ export const ShowBackups = ({ const queryMap = backupType === "database" ? { - postgres: () => - api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), - mysql: () => - api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), mariadb: () => api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + mysql: () => + api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), + postgres: () => + api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), + libsql: () => + api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), "web-server": () => api.user.getBackups.useQuery(), } : { @@ -77,10 +79,11 @@ export const ShowBackups = ({ const mutationMap = backupType === "database" ? { - postgres: api.backup.manualBackupPostgres.useMutation(), - mysql: api.backup.manualBackupMySql.useMutation(), mariadb: api.backup.manualBackupMariadb.useMutation(), mongo: api.backup.manualBackupMongo.useMutation(), + mysql: api.backup.manualBackupMySql.useMutation(), + postgres: api.backup.manualBackupPostgres.useMutation(), + libsql: api.backup.manualBackupLibsql.useMutation(), "web-server": api.backup.manualBackupWebServer.useMutation(), } : { @@ -89,11 +92,11 @@ export const ShowBackups = ({ const mutation = mutationMap[key as keyof typeof mutationMap]; - const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutation + const { mutateAsync: manualBackup, isPending: isManualBackup } = mutation ? mutation : api.backup.manualBackupMongo.useMutation(); - const { mutateAsync: deleteBackup, isLoading: isRemoving } = + const { mutateAsync: deleteBackup, isPending: isRemoving } = api.backup.remove.useMutation(); return ( diff --git a/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx b/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx new file mode 100644 index 000000000..770d4efd0 --- /dev/null +++ b/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx @@ -0,0 +1,613 @@ +"use client"; + +import { + type ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import type { inferRouterOutputs } from "@trpc/server"; +import { + ArrowUpDown, + Boxes, + ChevronLeft, + ChevronRight, + ExternalLink, + Loader2, + Rocket, + Server, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { AppRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +type DeploymentRow = + inferRouterOutputs["deployment"]["allCentralized"][number]; + +const statusVariants: Record< + string, + | "default" + | "secondary" + | "destructive" + | "outline" + | "yellow" + | "green" + | "red" +> = { + running: "yellow", + done: "green", + error: "red", + cancelled: "outline", +}; + +function getServiceInfo(d: DeploymentRow) { + const app = d.application; + const comp = d.compose; + if (app?.environment?.project && app.environment) { + return { + type: "Application" as const, + name: app.name, + projectId: app.environment.project.projectId, + environmentId: app.environment.environmentId, + projectName: app.environment.project.name, + environmentName: app.environment.name, + serviceId: app.applicationId, + href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`, + }; + } + if (comp?.environment?.project && comp.environment) { + return { + type: "Compose" as const, + name: comp.name, + projectId: comp.environment.project.projectId, + environmentId: comp.environment.environmentId, + projectName: comp.environment.project.name, + environmentName: comp.environment.name, + serviceId: comp.composeId, + href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`, + }; + } + return null; +} + +export function ShowDeploymentsTable() { + const [sorting, setSorting] = useState([ + { id: "createdAt", desc: true }, + ]); + const [columnFilters, setColumnFilters] = useState([]); + const [globalFilter, setGlobalFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("all"); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 50, + }); + + const { data: deploymentsList, isLoading } = + api.deployment.allCentralized.useQuery(undefined, { + refetchInterval: 5000, + }); + + const filteredData = useMemo(() => { + if (!deploymentsList) return []; + let list = deploymentsList; + if (statusFilter !== "all") { + list = list.filter((d) => d.status === statusFilter); + } + if (typeFilter === "application") { + list = list.filter((d) => d.applicationId != null); + } else if (typeFilter === "compose") { + list = list.filter((d) => d.composeId != null); + } + if (globalFilter.trim()) { + const q = globalFilter.toLowerCase(); + list = list.filter((d) => { + const info = getServiceInfo(d); + const serverName = + d.server?.name ?? + d.application?.server?.name ?? + d.compose?.server?.name ?? + ""; + const buildServerName = + d.buildServer?.name ?? d.application?.buildServer?.name ?? ""; + if (!info) return false; + return ( + info.name.toLowerCase().includes(q) || + info.projectName.toLowerCase().includes(q) || + info.environmentName.toLowerCase().includes(q) || + (d.title?.toLowerCase().includes(q) ?? false) || + serverName.toLowerCase().includes(q) || + buildServerName.toLowerCase().includes(q) + ); + }); + } + return list; + }, [deploymentsList, statusFilter, typeFilter, globalFilter]); + + const columns = useMemo( + () => [ + { + id: "serviceName", + accessorFn: (row: DeploymentRow) => getServiceInfo(row)?.name ?? "", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const info = getServiceInfo(row.original); + if (!info) return ; + return ( +
+ {info.type === "Application" ? ( + + ) : ( + + )} +
+ {info.name} + + {info.type} + +
+
+ ); + }, + }, + { + id: "projectName", + accessorFn: (row: DeploymentRow) => + getServiceInfo(row)?.projectName ?? "", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const info = getServiceInfo(row.original); + return ( + + {info?.projectName ?? "—"} + + ); + }, + }, + { + id: "environmentName", + accessorFn: (row: DeploymentRow) => + getServiceInfo(row)?.environmentName ?? "", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const info = getServiceInfo(row.original); + return ( + + {info?.environmentName ?? "—"} + + ); + }, + }, + { + id: "serverName", + accessorFn: (row: DeploymentRow) => + row.server?.name ?? + row.application?.server?.name ?? + row.compose?.server?.name ?? + "", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const d = row.original; + const serverName = + d.server?.name ?? + d.application?.server?.name ?? + d.compose?.server?.name ?? + null; + const serverType = + d.server?.serverType ?? + d.application?.server?.serverType ?? + d.compose?.server?.serverType ?? + null; + const buildServerName = + d.buildServer?.name ?? d.application?.buildServer?.name ?? null; + const buildServerType = + d.buildServer?.serverType ?? + d.application?.buildServer?.serverType ?? + null; + const showBuild = + buildServerName != null && buildServerName !== serverName; + if (!serverName && !showBuild) { + return ; + } + return ( +
+ {serverName && ( +
+ + {serverName} + {serverType && ( + + {serverType} + + )} +
+ )} + {showBuild && buildServerName && ( +
+ Build: + {buildServerName} + {buildServerType && ( + + {buildServerType} + + )} +
+ )} +
+ ); + }, + }, + { + accessorKey: "title", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => ( + + {row.original.title || "—"} + + ), + }, + { + accessorKey: "status", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const status = row.original.status ?? "running"; + return ( + + {status} + + ); + }, + }, + { + accessorKey: "createdAt", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => ( + + {row.original.createdAt + ? new Date(row.original.createdAt).toLocaleString() + : "—"} + + ), + }, + { + header: "", + id: "actions", + enableSorting: false, + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const info = getServiceInfo(row.original); + if (!info) return null; + return ( + + ); + }, + }, + ], + [], + ); + + const table = useReactTable({ + data: filteredData, + columns, + state: { + sorting, + columnFilters, + globalFilter, + pagination, + }, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onGlobalFilterChange: setGlobalFilter, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> + + +
+
+ {isLoading ? ( +
+ + Loading deployments... +
+ ) : ( + <> +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + +
+ +

No deployments found

+

+ Deployments from applications and compose will + appear here. +

+
+
+
+ )} +
+
+
+
+
+ + Rows per page + + + + Showing{" "} + {filteredData.length === 0 + ? 0 + : pagination.pageIndex * pagination.pageSize + 1}{" "} + to{" "} + {Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + filteredData.length, + )}{" "} + of {filteredData.length} entries + +
+
+ + +
+
+ + )} +
+
+ ); +} diff --git a/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx b/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx new file mode 100644 index 000000000..22b132f16 --- /dev/null +++ b/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx @@ -0,0 +1,217 @@ +"use client"; + +import type { inferRouterOutputs } from "@trpc/server"; +import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react"; +import Link from "next/link"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { AppRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +type QueueRow = + inferRouterOutputs["deployment"]["queueList"][number]; + +const stateVariants: Record< + string, + | "default" + | "secondary" + | "destructive" + | "outline" + | "yellow" + | "green" + | "red" +> = { + pending: "secondary", + waiting: "secondary", + active: "yellow", + delayed: "outline", + completed: "green", + failed: "destructive", + cancelled: "outline", + paused: "outline", +}; + +function formatTs(ts?: number): string { + if (ts == null) return "—"; + const d = new Date(ts); + return d.toLocaleString(); +} + +function getJobLabel(row: QueueRow): string { + const d = row.data as { + applicationType?: string; + applicationId?: string; + composeId?: string; + previewDeploymentId?: string; + titleLog?: string; + type?: string; + }; + if (!d) return String(row.id); + const type = d.applicationType ?? "job"; + const title = d.titleLog ?? ""; + if (title) return title; + if (d.applicationId) return `Application ${d.applicationId.slice(0, 8)}…`; + if (d.composeId) return `Compose ${d.composeId.slice(0, 8)}…`; + if (d.previewDeploymentId) + return `Preview ${d.previewDeploymentId.slice(0, 8)}…`; + return `${type} ${String(row.id)}`; +} + +export function ShowQueueTable(props: { embedded?: boolean }) { + const { embedded: _embedded = false } = props; + const { data: queueList, isLoading } = api.deployment.queueList.useQuery( + undefined, + { refetchInterval: 3000 }, + ); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const utils = api.useUtils(); + const { + mutateAsync: cancelApplicationDeployment, + isPending: isCancellingApp, + } = api.application.cancelDeployment.useMutation({ + onSuccess: () => void utils.deployment.queueList.invalidate(), + }); + const { + mutateAsync: cancelComposeDeployment, + isPending: isCancellingCompose, + } = api.compose.cancelDeployment.useMutation({ + onSuccess: () => void utils.deployment.queueList.invalidate(), + }); + const isCancelling = isCancellingApp || isCancellingCompose; + + return ( +
+ {isLoading ? ( +
+ + Loading queue... +
+ ) : ( +
+ + + + Job ID + Label + Type + State + Added + Processed + Finished + Error + Actions + + + + {queueList?.length ? ( + queueList.map((row) => { + const d = row.data as Record; + const appType = d?.applicationType as string | undefined; + const pathInfo = row.servicePath; + const hasLink = pathInfo?.href != null; + return ( + + + {String(row.id)} + + + {getJobLabel(row)} + + {appType ?? row.name ?? "—"} + + + {row.state} + + + + {formatTs(row.timestamp)} + + + {formatTs(row.processedOn)} + + + {formatTs(row.finishedOn)} + + + {row.failedReason ?? "—"} + + +
+ {hasLink ? ( + + ) : ( + + — + + )} + {isCloud && + row.state === "active" && + (d?.applicationId != null || + d?.composeId != null) && ( + + )} +
+
+
+ ); + }) + ) : ( + + +
+ +

Queue is empty

+

+ Deployment jobs will appear here when they are queued. +

+
+
+
+ )} +
+
+
+ )} +
+ ); +} diff --git a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx index bf0173956..59b939008 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -402,7 +402,7 @@ export const DockerLogsId: React.FC = ({ {filteredLogs.length > 0 ? ( filteredLogs.map((filteredLog: LogLine, index: number) => ( {" "}
- {/* Icon to expand the log item maybe implement a colapsible later */} + {/* Icon to expand the log item maybe implement a collapsible later */} {/* */} {tooltip(color, rawTimestamp)} {!noTimestamp && ( diff --git a/apps/dokploy/components/dashboard/docker/remove/remove-container.tsx b/apps/dokploy/components/dashboard/docker/remove/remove-container.tsx new file mode 100644 index 000000000..3b6cd9875 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/remove/remove-container.tsx @@ -0,0 +1,66 @@ +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { api } from "@/utils/api"; + +interface Props { + containerId: string; + serverId?: string; +} + +export const RemoveContainerDialog = ({ containerId, serverId }: Props) => { + const utils = api.useUtils(); + const { mutateAsync, isPending } = api.docker.removeContainer.useMutation(); + + return ( + + + e.preventDefault()} + > + Remove Container + + + + + Are you sure? + + This will permanently remove the container{" "} + {containerId}. If the + container is running, it will be forcefully stopped and removed. + This action cannot be undone. + + + + Cancel + { + await mutateAsync({ containerId, serverId }) + .then(async () => { + toast.success("Container removed successfully"); + await utils.docker.getContainers.invalidate(); + }) + .catch((err) => { + toast.error(err.message); + }); + }} + > + Confirm + + + + + ); +}; diff --git a/apps/dokploy/components/dashboard/docker/show/colums.tsx b/apps/dokploy/components/dashboard/docker/show/columns.tsx similarity index 88% rename from apps/dokploy/components/dashboard/docker/show/colums.tsx rename to apps/dokploy/components/dashboard/docker/show/columns.tsx index 74fe6819e..33c104d97 100644 --- a/apps/dokploy/components/dashboard/docker/show/colums.tsx +++ b/apps/dokploy/components/dashboard/docker/show/columns.tsx @@ -10,7 +10,9 @@ import { } from "@/components/ui/dropdown-menu"; import { ShowContainerConfig } from "../config/show-container-config"; import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs"; +import { RemoveContainerDialog } from "../remove/remove-container"; import { DockerTerminalModal } from "../terminal/docker-terminal-modal"; +import { UploadFileModal } from "../upload/upload-file-modal"; import type { Container } from "./show-containers"; export const columns: ColumnDef[] = [ @@ -127,6 +129,16 @@ export const columns: ColumnDef[] = [ > Terminal + + Upload File + + ); diff --git a/apps/dokploy/components/dashboard/docker/show/show-containers.tsx b/apps/dokploy/components/dashboard/docker/show/show-containers.tsx index 52398aabe..8a19566e8 100644 --- a/apps/dokploy/components/dashboard/docker/show/show-containers.tsx +++ b/apps/dokploy/components/dashboard/docker/show/show-containers.tsx @@ -35,7 +35,7 @@ import { TableRow, } from "@/components/ui/table"; import { api, type RouterOutputs } from "@/utils/api"; -import { columns } from "./colums"; +import { columns } from "./columns"; export type Container = NonNullable< RouterOutputs["docker"]["getContainers"] >[0]; @@ -45,7 +45,7 @@ interface Props { } export const ShowContainers = ({ serverId }: Props) => { - const { data, isLoading } = api.docker.getContainers.useQuery({ + const { data, isPending } = api.docker.getContainers.useQuery({ serverId, }); @@ -137,7 +137,7 @@ export const ShowContainers = ({ serverId }: Props) => {
- {isLoading ? ( + {isPending ? (
Loading... @@ -192,7 +192,7 @@ export const ShowContainers = ({ serverId }: Props) => { colSpan={columns.length} className="h-24 text-center" > - {isLoading ? ( + {isPending ? (
Loading... diff --git a/apps/dokploy/components/dashboard/docker/upload/upload-file-modal.tsx b/apps/dokploy/components/dashboard/docker/upload/upload-file-modal.tsx new file mode 100644 index 000000000..8838ac094 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/upload/upload-file-modal.tsx @@ -0,0 +1,187 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { Upload } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { Dropzone } from "@/components/ui/dropzone"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { api } from "@/utils/api"; +import { + uploadFileToContainerSchema, + type UploadFileToContainer, +} from "@/utils/schema"; + +interface Props { + containerId: string; + serverId?: string; + children?: React.ReactNode; +} + +export const UploadFileModal = ({ children, containerId, serverId }: Props) => { + const [open, setOpen] = useState(false); + + const { mutateAsync: uploadFile, isPending: isLoading } = + api.docker.uploadFileToContainer.useMutation({ + onSuccess: () => { + toast.success("File uploaded successfully"); + setOpen(false); + form.reset(); + }, + onError: (error) => { + toast.error(error.message || "Failed to upload file to container"); + }, + }); + + const form = useForm({ + resolver: zodResolver(uploadFileToContainerSchema), + defaultValues: { + containerId, + destinationPath: "/", + serverId: serverId || undefined, + }, + }); + + const file = form.watch("file"); + + const onSubmit = async (values: UploadFileToContainer) => { + if (!values.file) { + toast.error("Please select a file to upload"); + return; + } + + const formData = new FormData(); + formData.append("containerId", values.containerId); + formData.append("file", values.file); + formData.append("destinationPath", values.destinationPath); + if (values.serverId) { + formData.append("serverId", values.serverId); + } + + await uploadFile(formData); + }; + + return ( + + + e.preventDefault()} + > + {children} + + + + + + + Upload File to Container + + + Upload a file directly into the container's filesystem + + + +
+ + ( + + Destination Path + + + + +

+ Enter the full path where the file should be uploaded in the + container (e.g., /app/config.json) +

+
+ )} + /> + + ( + + File + + { + if (files && files.length > 0) { + field.onChange(files[0]); + } else { + field.onChange(null); + } + }} + /> + + + {file instanceof File && ( +
+ + {file.name} ({(file.size / 1024).toFixed(2)} KB) + + +
+ )} +
+ )} + /> + + + + + + + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx b/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx index 8c848a0dc..288208fb1 100644 --- a/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx +++ b/apps/dokploy/components/dashboard/file-system/show-traefik-file.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { Loader2 } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -7,6 +7,7 @@ import { z } from "zod"; import { AlertBlock } from "@/components/shared/alert-block"; import { CodeEditor } from "@/components/shared/code-editor"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Form, FormControl, @@ -16,6 +17,7 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; +import { Label } from "@/components/ui/label"; import { api } from "@/utils/api"; import { validateAndFormatYAML } from "../application/advanced/traefik/update-traefik-config"; @@ -47,8 +49,9 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => { }, ); const [canEdit, setCanEdit] = useState(true); + const [skipYamlValidation, setSkipYamlValidation] = useState(false); - const { mutateAsync, isLoading, error, isError } = + const { mutateAsync, isPending, error, isError } = api.settings.updateTraefikFile.useMutation(); const form = useForm({ @@ -66,13 +69,15 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => { }, [form, form.reset, data]); const onSubmit = async (data: UpdateServerMiddlewareConfig) => { - const { valid, error } = validateAndFormatYAML(data.traefikConfig); - if (!valid) { - form.setError("traefikConfig", { - type: "manual", - message: error || "Invalid YAML", - }); - return; + if (!skipYamlValidation) { + const { valid, error } = validateAndFormatYAML(data.traefikConfig); + if (!valid) { + form.setError("traefikConfig", { + type: "manual", + message: error || "Invalid YAML", + }); + return; + } } form.clearErrors("traefikConfig"); await mutateAsync({ @@ -153,14 +158,37 @@ routers: /> )}
-
- +
+
+ + setSkipYamlValidation(checked === true) + } + /> + +
+

+ Traefik supports Go templating in dynamic configs (e.g.{" "} + {"{{range}}"}). Configs using + templates will fail standard YAML validation. Check this to save + without validation. +

+
+ +
diff --git a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx index f77983996..1f0c6924c 100644 --- a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx +++ b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx @@ -45,10 +45,12 @@ import { import { authClient } from "@/lib/auth-client"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; +import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling"; type User = typeof authClient.$Infer.Session.user; export const ImpersonationBar = () => { + const { config: whitelabeling } = useWhitelabeling(); const [users, setUsers] = useState([]); const [selectedUser, setSelectedUser] = useState(null); const [isImpersonating, setIsImpersonating] = useState(false); @@ -180,7 +182,10 @@ export const ImpersonationBar = () => { )} >
- + {!isImpersonating ? (
diff --git a/apps/dokploy/components/dashboard/libsql/general/show-external-libsql-credentials.tsx b/apps/dokploy/components/dashboard/libsql/general/show-external-libsql-credentials.tsx new file mode 100644 index 000000000..378d0d944 --- /dev/null +++ b/apps/dokploy/components/dashboard/libsql/general/show-external-libsql-credentials.tsx @@ -0,0 +1,251 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api } from "@/utils/api"; + +const DockerProviderSchema = z.object({ + externalPort: z.preprocess((a) => { + if (a === null || a === undefined || a === "") return null; + const parsed = Number.parseInt(String(a), 10); + return Number.isNaN(parsed) ? null : parsed; + }, z + .number() + .gte(0, "Range must be 0 - 65535") + .lte(65535, "Range must be 0 - 65535") + .nullable()), + externalGRPCPort: z.preprocess((a) => { + if (a === null || a === undefined || a === "") return null; + const parsed = Number.parseInt(String(a), 10); + return Number.isNaN(parsed) ? null : parsed; + }, z + .number() + .gte(0, "Range must be 0 - 65535") + .lte(65535, "Range must be 0 - 65535") + .nullable()), + externalAdminPort: z.preprocess((a) => { + if (a === null || a === undefined || a === "") return null; + const parsed = Number.parseInt(String(a), 10); + return Number.isNaN(parsed) ? null : parsed; + }, z + .number() + .gte(0, "Range must be 0 - 65535") + .lte(65535, "Range must be 0 - 65535") + .nullable()), +}); + +type DockerProvider = z.infer; + +interface Props { + libsqlId: string; +} +export const ShowExternalLibsqlCredentials = ({ libsqlId }: Props) => { + const { data: ip } = api.settings.getIp.useQuery(); + const { data, refetch } = api.libsql.one.useQuery({ libsqlId }); + const { mutateAsync, isPending } = api.libsql.saveExternalPorts.useMutation(); + const [connectionUrl, setConnectionUrl] = useState(""); + const [connectionGRPCUrl, setGRPCConnectionUrl] = useState(""); + const getIp = data?.server?.ipAddress || ip; + + const form = useForm({ + defaultValues: {}, + resolver: zodResolver(DockerProviderSchema), + }); + + useEffect(() => { + if (data) { + form.reset({ + externalPort: data.externalPort, + externalGRPCPort: data.externalGRPCPort, + externalAdminPort: data.externalAdminPort, + }); + } + }, [form.reset, data, form]); + + const onSubmit = async (values: DockerProvider) => { + await mutateAsync({ + externalPort: values.externalPort, + externalGRPCPort: values.externalGRPCPort, + externalAdminPort: values.externalAdminPort, + libsqlId, + }) + .then(async () => { + toast.success("External port/ports updated"); + await refetch(); + }) + .catch((error: Error) => { + toast.error(error?.message || "Error saving the external port/ports"); + }); + }; + + useEffect(() => { + const port = form.watch("externalPort") || data?.externalPort; + setConnectionUrl( + `http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`, + ); + + if (data?.sqldNode !== "replica") { + const grpcPort = form.watch("externalGRPCPort") || data?.externalGRPCPort; + setGRPCConnectionUrl( + `http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${grpcPort}`, + ); + } + }, [ + data?.externalGRPCPort, + data?.databasePassword, + form, + data?.databaseUser, + getIp, + ]); + + return ( +
+ + + External Credentials + + In order to make the database reachable through the internet, you + must set a port and ensure that the port is not being used by + another application or database + + + + {!getIp && ( + + You need to set an IP address in your{" "} + + {data?.serverId + ? "Remote Servers -> Server -> Edit Server -> Update IP Address" + : "Web Server -> Server -> Update Server IP"} + {" "} + to fix the database url connection. + + )} +
+ +
+
+ ( + + External Port (Internet) + + + + + + )} + /> +
+
+ {!!data?.externalPort && ( +
+
+ + +
+
+ )} + +
+
+ ( + + External Admin Port (Internet) + + + + + + )} + /> +
+
+ + {data?.sqldNode !== "replica" && ( + <> +
+
+ ( + + External GRPC Port (Internet) + + + + + + )} + /> +
+
+ {!!data?.externalGRPCPort && ( +
+
+ + +
+
+ )} + + )} + +
+ +
+
+ +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/libsql/general/show-general-libsql.tsx b/apps/dokploy/components/dashboard/libsql/general/show-general-libsql.tsx new file mode 100644 index 000000000..1727bb2b1 --- /dev/null +++ b/apps/dokploy/components/dashboard/libsql/general/show-general-libsql.tsx @@ -0,0 +1,268 @@ +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { DrawerLogs } from "@/components/shared/drawer-logs"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { api } from "@/utils/api"; +import { type LogLine, parseLogs } from "../../docker/logs/utils"; +import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal"; + +interface Props { + libsqlId: string; +} + +export const ShowGeneralLibsql = ({ libsqlId }: Props) => { + const { data, refetch } = api.libsql.one.useQuery( + { + libsqlId, + }, + { enabled: !!libsqlId }, + ); + + const { mutateAsync: reload, isPending: isReloading } = + api.libsql.reload.useMutation(); + + const { mutateAsync: start, isPending: isStarting } = + api.libsql.start.useMutation(); + + const { mutateAsync: stop, isPending: isStopping } = + api.libsql.stop.useMutation(); + + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [filteredLogs, setFilteredLogs] = useState([]); + const [isDeploying, setIsDeploying] = useState(false); + api.libsql.deployWithLogs.useSubscription( + { + libsqlId: libsqlId, + }, + { + enabled: isDeploying, + onData(log) { + if (!isDrawerOpen) { + setIsDrawerOpen(true); + } + + if (log === "Deployment completed successfully!") { + setIsDeploying(false); + } + const parsedLogs = parseLogs(log); + setFilteredLogs((prev) => [...prev, ...parsedLogs]); + }, + onError(error) { + console.error("Deployment logs error:", error); + setIsDeploying(false); + }, + }, + ); + + return ( + <> +
+ + + Deploy Settings + + + + { + setIsDeploying(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + refetch(); + }} + > + + + + + { + await reload({ + libsqlId: libsqlId, + appName: data?.appName || "", + }) + .then(() => { + toast.success("Libsql reloaded successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error reloading Libsql"); + }); + }} + > + + + + {data?.applicationStatus === "idle" ? ( + + { + await start({ + libsqlId: libsqlId, + }) + .then(() => { + toast.success("Libsql started successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error starting Libsql"); + }); + }} + > + + + + ) : ( + + { + await stop({ + libsqlId: libsqlId, + }) + .then(() => { + toast.success("Libsql stopped successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error stopping Libsql"); + }); + }} + > + + + + )} + + + + + + { + setIsDrawerOpen(false); + setFilteredLogs([]); + setIsDeploying(false); + refetch(); + }} + filteredLogs={filteredLogs} + /> +
+ + ); +}; diff --git a/apps/dokploy/components/dashboard/libsql/general/show-internal-libsql-credentials.tsx b/apps/dokploy/components/dashboard/libsql/general/show-internal-libsql-credentials.tsx new file mode 100644 index 000000000..6c1350242 --- /dev/null +++ b/apps/dokploy/components/dashboard/libsql/general/show-internal-libsql-credentials.tsx @@ -0,0 +1,121 @@ +import { SelectGroup } from "@radix-ui/react-select"; +import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; + +interface Props { + libsqlId: string; +} +export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => { + const { data } = api.libsql.one.useQuery({ libsqlId }); + return ( + <> +
+ + + Internal Credentials + + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+
+
+
+ + ); +}; diff --git a/apps/dokploy/components/dashboard/libsql/update-libsql.tsx b/apps/dokploy/components/dashboard/libsql/update-libsql.tsx new file mode 100644 index 000000000..99455531a --- /dev/null +++ b/apps/dokploy/components/dashboard/libsql/update-libsql.tsx @@ -0,0 +1,163 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { PenBoxIcon } from "lucide-react"; +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 { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/utils/api"; + +const updateLibsqlSchema = z.object({ + name: z.string().min(1, { + message: "Name is required", + }), + description: z.string().optional(), +}); + +type UpdateLibsql = z.infer; + +interface Props { + libsqlId: string; +} + +export const UpdateLibsql = ({ libsqlId }: Props) => { + const utils = api.useUtils(); + const { mutateAsync, error, isError, isPending } = + api.libsql.update.useMutation(); + const { data } = api.libsql.one.useQuery( + { + libsqlId, + }, + { + enabled: !!libsqlId, + }, + ); + const form = useForm({ + defaultValues: { + description: data?.description ?? "", + name: data?.name ?? "", + }, + resolver: zodResolver(updateLibsqlSchema), + }); + useEffect(() => { + if (data) { + form.reset({ + description: data.description ?? "", + name: data.name, + }); + } + }, [data, form, form.reset]); + + const onSubmit = async (formData: UpdateLibsql) => { + await mutateAsync({ + name: formData.name, + libsqlId: libsqlId, + description: formData.description || "", + }) + .then(() => { + toast.success("Libsql updated successfully"); + utils.libsql.one.invalidate({ + libsqlId: libsqlId, + }); + }) + .catch(() => { + toast.error("Error updating the Libsql"); + }) + .finally(() => {}); + }; + + return ( + + + + + + + Modify Libsql + Update the Libsql data + + {isError && {error?.message}} + +
+
+
+ + ( + + Name + + + + + + + )} + /> + ( + + Description + +