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 index 3554babb2..6de85fa27 100644 --- a/.github/workflows/pr-quality.yml +++ b/.github/workflows/pr-quality.yml @@ -16,7 +16,6 @@ jobs: steps: - uses: peakoss/anti-slop@v0 with: - max-failures: 4 blocked-commit-authors: "claude,copilot" require-description: true min-account-age: 5 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3896bb9dc..4fa0dd358 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -99,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 @@ -165,10 +172,11 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0. ### Important Considerations for Pull Requests -- **Testing is Mandatory:** All Pull Requests **must be tested** before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested will be closed.** This policy ensures clean contributions and reduces the time maintainers spend reviewing untested or broken code. +- **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/README.md b/README.md index 927e6ebc6..6a72f10d9 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. @@ -39,7 +39,7 @@ To get started, run the following command on a VPS: Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com). ```bash -curl -sSL https://dokploy.com/install.sh | sh +curl -sSL https://dokploy.com/install.sh | bash ``` For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com). 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 d8e19662a..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,7 +12,7 @@ "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.11.7", "pino": "9.4.0", @@ -20,12 +20,13 @@ "react": "18.2.0", "react-dom": "18.2.0", "redis": "4.7.0", - "zod": "^3.25.76" + "zod": "^4.3.6" }, "devDependencies": { "@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" }, 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/__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/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 c81fab44c..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(), @@ -31,6 +36,9 @@ vi.mock("@dokploy/server/db", () => { 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 498281776..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(), @@ -32,6 +37,9 @@ vi.mock("@dokploy/server/db", () => { 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 d2e773dfc..104e108f1 100644 --- a/apps/dokploy/__test__/deploy/github.test.ts +++ b/apps/dokploy/__test__/deploy/github.test.ts @@ -415,5 +415,24 @@ describe("Docker Image Name and Tag Extraction", () => { expect(extractImageTag("my-image:123")).toBe("123"); expect(extractImageTag("my-image:1")).toBe("1"); }); + + it("should return 'latest' for registry with port but no tag", () => { + expect(extractImageTag("registry.example.com:5000/myimage")).toBe( + "latest", + ); + expect(extractImageTag("registry:5000/fedora/httpd")).toBe("latest"); + expect(extractImageTag("localhost:5000/myapp")).toBe("latest"); + expect(extractImageTag("my-registry.io:443/org/app")).toBe("latest"); + }); + + it("should extract tag from registry with port and tag", () => { + expect(extractImageTag("registry:5000/image:tag")).toBe("tag"); + expect(extractImageTag("registry.example.com:5000/myimage:v2.0")).toBe( + "v2.0", + ); + expect(extractImageTag("localhost:5000/app:sha-abc123")).toBe( + "sha-abc123", + ); + }); }); }); diff --git a/apps/dokploy/__test__/drop/drop.test.ts b/apps/dokploy/__test__/drop/drop.test.ts index 6e9940d6d..a524e8da0 100644 --- a/apps/dokploy/__test__/drop/drop.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.ts @@ -120,6 +120,7 @@ const baseApp: ApplicationNested = { environmentId: "", enabled: null, env: null, + icon: null, healthCheckSwarm: null, labelsSwarm: null, memoryLimit: null, 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 fb448e3af..daf2dbe54 100644 --- a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts +++ b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts @@ -57,7 +57,7 @@ const createApplication = ( env: null, }, replicas: 1, - stopGracePeriodSwarm: 0n, + stopGracePeriodSwarm: 0, ulimitsSwarm: null, serverId: "server-id", ...overrides, @@ -76,8 +76,8 @@ describe("mechanizeDockerContainer", () => { }); }); - it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => { - const application = createApplication({ stopGracePeriodSwarm: 0n }); + it("passes stopGracePeriodSwarm as a number and keeps zero values", async () => { + const application = createApplication({ stopGracePeriodSwarm: 0 }); await mechanizeDockerContainer(application); 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 9121dc8a1..14d45f76c 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -95,6 +95,7 @@ const baseApp: ApplicationNested = { dropBuildPath: null, enabled: null, env: null, + icon: null, healthCheckSwarm: null, labelsSwarm: null, memoryLimit: null, @@ -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 () => { @@ -276,6 +353,130 @@ 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("stripPath and internalPath together: stripprefix must come before addprefix", async () => { + const router = await createRouterConfig( + baseApp, + { + ...baseDomain, + path: "/public", + stripPath: true, + internalPath: "/app/v2", + }, + "web", + ); + + const stripIndex = router.middlewares?.indexOf("stripprefix--1") ?? -1; + const addIndex = router.middlewares?.indexOf("addprefix--1") ?? -1; + + expect(stripIndex).toBeGreaterThanOrEqual(0); + expect(addIndex).toBeGreaterThanOrEqual(0); + expect(stripIndex).toBeLessThan(addIndex); +}); + +test("Custom entrypoint with https and custom cert resolver", async () => { + const router = await createRouterConfig( + baseApp, + { + ...baseDomain, + https: true, + customEntrypoint: "custom", + certificateType: "custom", + customCertResolver: "myresolver", + }, + "custom", + ); + + expect(router.entryPoints).toEqual(["custom"]); + expect(router.tls?.certResolver).toBe("myresolver"); +}); + +test("Custom entrypoint without https should not have tls", async () => { + const router = await createRouterConfig( + baseApp, + { + ...baseDomain, + https: false, + customEntrypoint: "custom", + certificateType: "letsencrypt", + }, + "custom", + ); + + expect(router.entryPoints).toEqual(["custom"]); + expect(router.tls).toBeUndefined(); +}); + /** IDN/Punycode */ test("Internationalized domain name is converted to punycode", async () => { 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 4c6fc60c7..7d214716e 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 @@ -112,14 +112,21 @@ const menuItems: MenuItem[] = [ const hasStopGracePeriodSwarm = ( value: unknown, -): value is { stopGracePeriodSwarm: bigint | number | string | null } => +): value is { stopGracePeriodSwarm: 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) => { 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/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 a6885a7e4..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"); @@ -122,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 index f2c640cfe..269d6f784 100644 --- 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 @@ -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"; @@ -35,7 +35,14 @@ export const networkFormSchema = z.object({ interface NetworkFormProps { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: + | "postgres" + | "mariadb" + | "mongo" + | "mysql" + | "redis" + | "application" + | "libsql"; } export const NetworkForm = ({ id, type }: NetworkFormProps) => { @@ -51,6 +58,7 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => { 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]() @@ -63,6 +71,7 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => { mariadb: () => api.mariadb.update.useMutation(), application: () => api.application.update.useMutation(), mongo: () => api.mongo.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), }; const { mutateAsync } = mutationMap[type] @@ -132,6 +141,7 @@ export const NetworkForm = ({ id, type }: NetworkFormProps) => { mysqlId: id || "", mariadbId: id || "", mongoId: id || "", + libsqlId: id || "", networkSwarm: networksToSend, }); 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..ebc93a388 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 @@ -16,14 +16,21 @@ import { api } from "@/utils/api"; const hasStopGracePeriodSwarm = ( value: unknown, -): value is { stopGracePeriodSwarm: bigint | number | string | null } => +): value is { stopGracePeriodSwarm: number | string | null } => typeof value === "object" && value !== null && "stopGracePeriodSwarm" in value; 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] @@ -59,7 +68,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => { const form = useForm({ defaultValues: { - value: null as bigint | null, + value: null as number | null, }, }); @@ -67,11 +76,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => { if (hasStopGracePeriodSwarm(data)) { const value = data.stopGracePeriodSwarm; const normalizedValue = - value === null || value === undefined - ? null - : typeof value === "bigint" - ? value - : BigInt(value); + value === null || value === undefined ? null : Number(value); form.reset({ value: normalizedValue, }); @@ -88,6 +93,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => { mysqlId: id || "", mariadbId: id || "", mongoId: id || "", + libsqlId: id || "", stopGracePeriodSwarm: formData.value, }); @@ -126,7 +132,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => { } onChange={(e) => field.onChange( - e.target.value ? BigInt(e.target.value) : null, + e.target.value ? Number(e.target.value) : null, ) } /> 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 8978d346a..fa2bda629 100644 --- a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx @@ -1,4 +1,4 @@ -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { InfoIcon, Plus, Trash2 } from "lucide-react"; import { useEffect } from "react"; import { useFieldArray, useForm } from "react-hook-form"; @@ -89,12 +89,13 @@ const ULIMIT_PRESETS = [ ]; export type ServiceType = - | "postgres" - | "mongo" - | "redis" - | "mysql" + | "application" + | "libsql" | "mariadb" - | "application"; + | "mongo" + | "mysql" + | "postgres" + | "redis"; interface Props { id: string; @@ -105,34 +106,36 @@ 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: "", @@ -155,19 +158,20 @@ export const ShowResources = ({ id, type }: Props) => { cpuReservation: data?.cpuReservation || undefined, memoryLimit: data?.memoryLimit || undefined, memoryReservation: data?.memoryReservation || undefined, - ulimitsSwarm: data?.ulimitsSwarm || [], + 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, @@ -452,6 +456,11 @@ export const ShowResources = ({ id, type }: Props) => { min={-1} placeholder="65535" {...field} + value={ + typeof field.value === "number" + ? field.value + : "" + } onChange={(e) => field.onChange(Number(e.target.value)) } @@ -475,6 +484,11 @@ export const ShowResources = ({ id, type }: Props) => { min={-1} placeholder="65535" {...field} + value={ + typeof field.value === "number" + ? field.value + : "" + } onChange={(e) => field.onChange(Number(e.target.value)) } @@ -507,7 +521,7 @@ export const ShowResources = ({ id, type }: Props) => {
-
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 928949d9f..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,7 +25,6 @@ import { FormLabel, FormMessage, } from "@/components/ui/form"; -import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { api } from "@/utils/api"; @@ -60,6 +60,8 @@ 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( @@ -69,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({ @@ -125,9 +127,11 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => { } }} > - - - + {canWrite && ( + + + + )} Update traefik config @@ -198,7 +202,7 @@ routers:

- + + + )} 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 6e19767b7..ed1373aa0 100644 --- a/apps/dokploy/components/dashboard/application/deployments/cancel-queues.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/cancel-queues.tsx @@ -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,7 +33,7 @@ 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 index 75862dfff..81f998a9d 100644 --- a/apps/dokploy/components/dashboard/application/deployments/clear-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/clear-deployments.tsx @@ -21,7 +21,7 @@ interface Props { export const ClearDeployments = ({ id, type }: Props) => { const utils = api.useUtils(); - const { mutateAsync, isLoading } = + const { mutateAsync, isPending } = type === "application" ? api.application.clearDeployments.useMutation() : api.compose.clearDeployments.useMutation(); @@ -29,7 +29,7 @@ export const ClearDeployments = ({ id, type }: Props) => { return ( - 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..9f078f9d2 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx @@ -1,6 +1,7 @@ import copy from "copy-to-clipboard"; import { Check, Copy, Loader2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { AnalyzeLogs } from "@/components/dashboard/docker/logs/analyze-logs"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -165,6 +166,7 @@ export const ShowDeployment = ({ )} + {serverId && (
@@ -194,13 +196,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 3f674aa3c..ccf2564b0 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -1,7 +1,9 @@ +import copy from "copy-to-clipboard"; import { ChevronDown, ChevronUp, Clock, + Copy, Loader2, RefreshCcw, RocketIcon, @@ -61,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, @@ -75,21 +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, isLoading: isRemovingDeployment } = + 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(""); @@ -97,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 => { @@ -224,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") && ( )} 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 77% rename from apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx rename to apps/dokploy/components/dashboard/application/environment/show-environment.tsx index 797a317a8..f5327818f 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,16 +56,17 @@ 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(), - mariadb: () => api.mariadb.update.useMutation(), - mongo: () => api.mongo.update.useMutation(), - compose: () => api.compose.update.useMutation(), + compose: () => api.compose.saveEnvironment.useMutation(), + libsql: () => api.libsql.saveEnvironment.useMutation(), + mariadb: () => api.mariadb.saveEnvironment.useMutation(), + mongo: () => api.mongo.saveEnvironment.useMutation(), + mysql: () => api.mysql.saveEnvironment.useMutation(), + postgres: () => api.postgres.saveEnvironment.useMutation(), + redis: () => api.redis.saveEnvironment.useMutation(), }; - const { mutateAsync, isLoading } = mutationMap[type] + const { mutateAsync, isPending } = mutationMap[type] ? mutationMap[type]() - : api.mongo.update.useMutation(); + : api.mongo.saveEnvironment.useMutation(); const form = useForm({ defaultValues: { @@ -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.code === "KeyS" && !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..fb5fc18a7 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.code === "KeyS" && !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 f8d5109c8..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: { @@ -333,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( @@ -350,7 +350,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { placeholder="Search branch..." className="h-9" /> - {status === "loading" && fetchStatus === "fetching" && ( + {status === "pending" && fetchStatus === "fetching" && ( Loading Branches.... @@ -416,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..cb3190e0b 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"; @@ -55,13 +55,13 @@ interface Props { export const SaveGitProvider = ({ applicationId }: Props) => { const { data, refetch } = api.application.one.useQuery({ applicationId }); - const { data: sshKeys } = api.sshKey.all.useQuery(); + const { data: sshKeys } = api.sshKey.allForApps.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 3f7943252..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: { @@ -353,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( @@ -371,7 +371,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { placeholder="Search branch..." className="h-9" /> - {status === "loading" && fetchStatus === "fetching" && ( + {status === "pending" && fetchStatus === "fetching" && ( Loading Branches.... @@ -463,7 +463,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => { { - const newPaths = [...field.value]; + const newPaths = [...(field.value || [])]; newPaths.splice(index, 1); field.onChange(newPaths); }} @@ -481,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 = ""; } } @@ -498,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 1fa42b9c0..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, @@ -320,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( @@ -337,7 +337,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => { placeholder="Search branch..." className="h-9" /> - {status === "loading" && fetchStatus === "fetching" && ( + {status === "pending" && fetchStatus === "fetching" && ( Loading Branches.... @@ -459,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 f5ba24e4c..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: { @@ -351,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( @@ -368,7 +368,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => { placeholder="Search branch..." className="h-9" /> - {status === "loading" && fetchStatus === "fetching" && ( + {status === "pending" && fetchStatus === "fetching" && ( Loading Branches.... @@ -448,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/icon/show-icon-settings.tsx b/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx new file mode 100644 index 000000000..ed2bd2675 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx @@ -0,0 +1,277 @@ +import DOMPurify from "dompurify"; +import { GlobeIcon, Pencil, Search, X } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Dropzone } from "@/components/ui/dropzone"; +import { Input } from "@/components/ui/input"; +import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons"; +import { api } from "@/utils/api"; + +interface ShowIconSettingsProps { + applicationId: string; + icon?: string | null; +} + +const svgToDataUrl = (icon: BundledIcon): string => { + const svg = ``; + return `data:image/svg+xml;base64,${btoa(svg)}`; +}; + +export const ShowIconSettings = ({ + applicationId, + icon, +}: ShowIconSettingsProps) => { + const [open, setOpen] = useState(false); + const [iconSearchQuery, setIconSearchQuery] = useState(""); + const [iconsToShow, setIconsToShow] = useState(24); + + const filteredIcons = useMemo(() => { + if (!iconSearchQuery) return bundledIcons; + const q = iconSearchQuery.toLowerCase(); + return bundledIcons.filter( + (i) => + i.title.toLowerCase().includes(q) || i.slug.toLowerCase().includes(q), + ); + }, [iconSearchQuery]); + + const displayedIcons = filteredIcons.slice(0, iconsToShow); + const hasMoreIcons = filteredIcons.length > iconsToShow; + + const utils = api.useUtils(); + const { mutateAsync: updateApplication } = + api.application.update.useMutation(); + + useEffect(() => { + if (open) { + setIconSearchQuery(""); + setIconsToShow(24); + } + }, [open]); + + const handleIconSelect = async (selectedIcon: BundledIcon) => { + try { + const dataUrl = svgToDataUrl(selectedIcon); + await updateApplication({ + applicationId, + icon: dataUrl, + }); + toast.success("Icon saved successfully"); + await utils.application.one.invalidate({ applicationId }); + setOpen(false); + } catch (_error) { + toast.error("Error saving icon"); + } + }; + + const handleRemoveIcon = async () => { + try { + await updateApplication({ + applicationId, + icon: null, + }); + toast.success("Icon removed"); + await utils.application.one.invalidate({ applicationId }); + } catch (_error) { + toast.error("Error removing icon"); + } + }; + + const sanitizeSvg = (svgContent: string): string | null => { + const clean = DOMPurify.sanitize(svgContent, { + USE_PROFILES: { svg: true, svgFilters: true }, + ADD_TAGS: ["use"], + }); + if (!clean) return null; + return `data:image/svg+xml;base64,${btoa(clean)}`; + }; + + const handleFileUpload = async (files: FileList | null) => { + if (!files || files.length === 0) return; + const file = files[0]; + if (!file) return; + + const allowedTypes = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/svg+xml", + ]; + const fileExtension = file.name.split(".").pop()?.toLowerCase(); + const allowedExtensions = ["jpg", "jpeg", "png", "svg"]; + + if ( + !allowedTypes.includes(file.type) && + !allowedExtensions.includes(fileExtension || "") + ) { + toast.error("Only JPG, JPEG, PNG, and SVG files are allowed"); + return; + } + + if (file.size > 2 * 1024 * 1024) { + toast.error("Image size must be less than 2MB"); + return; + } + + const isSvg = file.type === "image/svg+xml" || fileExtension === "svg"; + + if (isSvg) { + const text = await file.text(); + const sanitizedDataUrl = sanitizeSvg(text); + if (!sanitizedDataUrl) { + toast.error("Invalid SVG file"); + return; + } + try { + await updateApplication({ + applicationId, + icon: sanitizedDataUrl, + }); + toast.success("Icon saved!"); + await utils.application.one.invalidate({ applicationId }); + setOpen(false); + } catch (_error) { + toast.error("Error saving icon"); + } + return; + } + + const reader = new FileReader(); + reader.onload = async (event) => { + const result = event.target?.result as string; + try { + await updateApplication({ + applicationId, + icon: result, + }); + toast.success("Icon saved!"); + await utils.application.one.invalidate({ applicationId }); + setOpen(false); + } catch (_error) { + toast.error("Error saving icon"); + } + }; + reader.readAsDataURL(file); + }; + + return ( + + + + + + + + Change Icon + {icon && ( + + )} + + + +
+
+ + setIconSearchQuery(e.target.value)} + className="pl-9" + /> +
+ +
+ {displayedIcons.length === 0 ? ( +
+ No icons found +
+ ) : ( + <> +
+ {displayedIcons.map((i) => ( + + ))} +
+ {hasMoreIcons && ( +
+ +
+ )} + + )} +
+ +
+

+ or upload a custom icon +

+ +
+ Supported formats: JPG, JPEG, PNG, SVG (max 2MB) +
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/logs/show.tsx b/apps/dokploy/components/dashboard/application/logs/show.tsx index 941ddef50..06b257766 100644 --- a/apps/dokploy/components/dashboard/application/logs/show.tsx +++ b/apps/dokploy/components/dashboard/application/logs/show.tsx @@ -56,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, @@ -67,7 +67,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { }, ); - const { data: containers, isLoading: containersLoading } = + const { data: containers, isPending: containersLoading } = api.docker.getContainersByAppNameMatch.useQuery( { appName, @@ -91,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 ( @@ -167,7 +167,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { )} - Containers ({containersLenght}) + Containers ({containersLength}) diff --git a/apps/dokploy/components/dashboard/application/patches/edit-patch-dialog.tsx b/apps/dokploy/components/dashboard/application/patches/edit-patch-dialog.tsx index 284b62d10..8c5a42836 100644 --- a/apps/dokploy/components/dashboard/application/patches/edit-patch-dialog.tsx +++ b/apps/dokploy/components/dashboard/application/patches/edit-patch-dialog.tsx @@ -28,7 +28,7 @@ export const EditPatchDialog = ({ type, onSuccess, }: Props) => { - const { data: patch, isLoading: isPatchLoading } = api.patch.one.useQuery( + const { data: patch, isPending: isPatchLoading } = api.patch.one.useQuery( { patchId }, { enabled: !!patchId }, ); @@ -89,7 +89,7 @@ export const EditPatchDialog = ({ - 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..e9d024fd3 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.code === "KeyS" && !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 7622906df..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: { @@ -335,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( @@ -352,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..7878225a9 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"; @@ -55,12 +55,12 @@ interface Props { export const SaveGitProviderCompose = ({ composeId }: Props) => { const { data, refetch } = api.compose.one.useQuery({ composeId }); - const { data: sshKeys } = api.sshKey.all.useQuery(); + const { data: sshKeys } = api.sshKey.allForApps.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 5e546d050..7ea71fc89 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,5 +1,5 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react"; +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { CheckIcon, ChevronsUpDown, Plus, X, HelpCircle } from "lucide-react"; import Link from "next/link"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; @@ -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: { @@ -331,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( @@ -409,10 +409,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { Watch Paths - -
- ? -
+ +

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 b52fa2097..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, @@ -321,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( @@ -338,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 9f9babb3e..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: { @@ -353,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( @@ -370,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) => {
+ ), + 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/analyze-logs.tsx b/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx new file mode 100644 index 000000000..267735eac --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx @@ -0,0 +1,189 @@ +"use client"; +import { Bot, Loader2, RotateCcw, Settings, X } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import ReactMarkdown from "react-markdown"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; +import type { LogLine } from "./utils"; + +interface Props { + logs: LogLine[]; + context: "build" | "runtime"; +} + +const MAX_LOG_LINES = 200; + +export function AnalyzeLogs({ logs, context }: Props) { + const [open, setOpen] = useState(false); + const [aiId, setAiId] = useState(""); + const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, { + enabled: open, + }); + const { mutate, isPending, data, reset } = api.ai.analyzeLogs.useMutation({ + onError: (error) => { + toast.error("Analysis failed", { + description: error.message, + }); + }, + }); + + const handleAnalyze = () => { + if (!aiId || logs.length === 0) return; + + const logsText = logs + .slice(-MAX_LOG_LINES) + .map((l) => l.message) + .join("\n"); + + mutate({ aiId, logs: logsText, context }); + }; + + return ( + { + setOpen(isOpen); + if (!isOpen) { + reset(); + setAiId(""); + } + }} + > + + + + +
+
+ + Log Analysis +
+ +
+
+ {!data?.analysis ? ( + providers && providers.length === 0 ? ( +
+

+ No AI providers configured. Set up a provider to start + analyzing logs. +

+ +
+ ) : ( + <> + + + + ) + ) : ( + <> +
+
+ {data.analysis} +
+
+
+ + +
+ + )} +
+
+
+ ); +} 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..8d8842ac0 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -12,6 +12,7 @@ import { AlertBlock } from "@/components/shared/alert-block"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { api } from "@/utils/api"; +import { AnalyzeLogs } from "./analyze-logs"; import { LineCountFilter } from "./line-count-filter"; import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter"; import { StatusLogsFilter } from "./status-logs-filter"; @@ -377,6 +378,7 @@ export const DockerLogsId: React.FC = ({ Download logs +
{isPaused && ( @@ -402,7 +404,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/logs/utils.ts b/apps/dokploy/components/dashboard/docker/logs/utils.ts index 80a79eb2b..01c68e49a 100644 --- a/apps/dokploy/components/dashboard/docker/logs/utils.ts +++ b/apps/dokploy/components/dashboard/docker/logs/utils.ts @@ -74,6 +74,18 @@ export function parseLogs(logString: string): LogLine[] { // Detect log type based on message content export const getLogType = (message: string): LogStyle => { + // Detect HTTP statusCode + const statusMatch = message.match(/"statusCode"\s*:\s*"?(\d{3})"?/); + + if (statusMatch) { + const statusCode = Number(statusMatch[1]); + + if (statusCode >= 500) return LOG_STYLES.error; + if (statusCode >= 400) return LOG_STYLES.warning; + if (statusCode >= 200 && statusCode < 300) return LOG_STYLES.success; + return LOG_STYLES.info; + } + const lowerMessage = message.toLowerCase(); if ( 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 7e740a1b7..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"; @@ -51,7 +51,7 @@ 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({ @@ -182,8 +182,8 @@ routers:

+
+ + + + +
+ ); +}; 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 + +