diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml index 3554babb26..6de85fa270 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/.github/workflows/sync-openapi-docs.yml b/.github/workflows/sync-openapi-docs.yml index 549af945bc..1a6b1e87b2 100644 --- a/.github/workflows/sync-openapi-docs.yml +++ b/.github/workflows/sync-openapi-docs.yml @@ -68,3 +68,45 @@ jobs: echo "✅ OpenAPI synced to website successfully" + - name: Sync to MCP repository + run: | + git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo + + cd mcp-repo + + cp -f ../openapi.json openapi.json + + git config user.name "Dokploy Bot" + git config user.email "bot@dokploy.com" + + git add openapi.json + git commit -m "chore: sync OpenAPI specification [skip ci]" \ + -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + -m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \ + --allow-empty + + git push + + echo "✅ OpenAPI synced to MCP repository successfully" + + - name: Sync to CLI repository + run: | + git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo + + cd cli-repo + + cp -f ../openapi.json openapi.json + + git config user.name "Dokploy Bot" + git config user.email "bot@dokploy.com" + + git add openapi.json + git commit -m "chore: sync OpenAPI specification [skip ci]" \ + -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + -m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \ + --allow-empty + + git push + + echo "✅ OpenAPI synced to CLI repository successfully" + diff --git a/.github/workflows/sync-version.yml b/.github/workflows/sync-version.yml new file mode 100644 index 0000000000..4ef94f1a93 --- /dev/null +++ b/.github/workflows/sync-version.yml @@ -0,0 +1,79 @@ +name: Sync version to MCP and CLI repos + +on: + release: + types: [published] + +jobs: + sync-version: + name: Sync version to external repos + runs-on: ubuntu-latest + steps: + - name: Checkout Dokploy repository + uses: actions/checkout@v4 + + - name: Get version + id: get_version + run: | + VERSION=$(jq -r .version apps/dokploy/package.json) + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Version: $VERSION" + + - name: Sync version to MCP repository + run: | + git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo + cd mcp-repo + + # Bump version + jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp + mv package.json.tmp package.json + + # Regenerate tools from latest OpenAPI spec + npm install -g pnpm + pnpm install + pnpm run fetch-openapi + pnpm run generate + + git config user.name "Dokploy Bot" + git config user.email "bot@dokploy.com" + + git add -A + git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \ + -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + -m "Release: ${{ github.event.release.html_url }}" \ + --allow-empty + + git push + + + - name: Sync version to CLI repository + run: | + git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo + + cd cli-repo + + # Bump version + if [ -f package.json ]; then + jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp + mv package.json.tmp package.json + fi + + # Copy latest openapi spec and regenerate commands + cp ../openapi.json ./openapi.json + npm install -g pnpm + pnpm install + pnpm run generate + + git config user.name "Dokploy Bot" + git config user.email "bot@dokploy.com" + + git add -A + git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \ + -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + -m "Release: ${{ github.event.release.html_url }}" \ + --allow-empty + + git push + + echo "CLI repo synced to version ${{ steps.get_version.outputs.version }}" + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad37899e6e..4fa0dd358d 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 diff --git a/README.md b/README.md index 927e6ebc6a..6a72f10d9c 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/dokploy/__test__/compose/domain/host-rule-format.test.ts b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts index 097c916ea5..6f843b8a81 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 9a75e0a845..ec8e9edc70 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 0d3c841d40..1a68179137 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 e07fa15465..073e616159 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/github.test.ts b/apps/dokploy/__test__/deploy/github.test.ts index d2e773dfcd..104e108f1c 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 6e9940d6d7..a524e8da06 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 13f5adb536..773adf3ed2 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/enterprise-only-resources.test.ts b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts index 9568b12afd..bb6f5f18b0 100644 --- a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts +++ b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect } from "vitest"; import { enterpriseOnlyResources, statements, } from "@dokploy/server/lib/access-control"; +import { describe, expect, it } from "vitest"; const FREE_TIER_RESOURCES = [ "organization", @@ -35,6 +35,7 @@ const ENTERPRISE_RESOURCES = [ "domain", "destination", "notification", + "tag", "logs", "monitoring", "auditLog", diff --git a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts index fb448e3af8..daf2dbe542 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__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 9121dc8a1f..14d45f76c9 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 4c6fc60c7e..7d214716e4 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 8de863957e..95f8494807 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 @@ -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 = { - 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 }), + 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 }), }; const { data, refetch } = queryMap[type] ? queryMap[type]() @@ -65,12 +65,13 @@ export const ShowClusterSettings = ({ id, type }: Props) => { const { data: registries } = api.registry.all.useQuery(); const mutationMap = { - postgres: () => api.postgres.update.useMutation(), - redis: () => api.redis.update.useMutation(), - mysql: () => api.mysql.update.useMutation(), - mariadb: () => api.mariadb.update.useMutation(), application: () => api.application.update.useMutation(), + 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(), }; const { mutateAsync, isPending } = mutationMap[type] @@ -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: 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 6d95634be1..6ea18c6536 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 @@ -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 f62037fcaa..06c8eb94a6 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 @@ -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 41ce741ae2..02a480a036 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 @@ -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 a6885a7e4b..bd2eca18e9 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 7d6ebbaf34..269d6f784b 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 @@ -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 b4091aac0b..a4a6500204 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 @@ -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 db7be5629b..4aba01f03a 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 @@ -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 528b9d1cc1..081825e64f 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 @@ -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 a324da31b6..ebc93a3884 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 af2d826db5..ef9fe34bbb 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 @@ -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/redirects/handle-redirect.tsx b/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx index 172c042f19..683e0ebbaf 100644 --- a/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/redirects/handle-redirect.tsx @@ -37,13 +37,13 @@ import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import { api } from "@/utils/api"; -const AddRedirectchema = z.object({ +const AddRedirectSchema = z.object({ regex: z.string().min(1, "Regex required"), permanent: z.boolean().default(false), replacement: z.string().min(1, "Replacement required"), }); -type AddRedirect = z.infer; +type AddRedirect = z.infer; // Default presets const redirectPresets = [ @@ -110,7 +110,7 @@ export const HandleRedirect = ({ regex: "", replacement: "", }, - resolver: zodResolver(AddRedirectchema), + resolver: zodResolver(AddRedirectSchema), }); useEffect(() => { @@ -149,7 +149,7 @@ export const HandleRedirect = ({ const onDialogToggle = (open: boolean) => { setIsOpen(open); - // commented for the moment because not reseting the form if accidentally closed the dialog can be considered as a feature instead of a bug + // commented for the moment because not resetting the form if accidentally closed the dialog can be considered as a feature instead of a bug // setPresetSelected(""); // form.reset(); }; diff --git a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx index 3b30155bfc..fa2bda6293 100644 --- a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx @@ -89,12 +89,13 @@ const ULIMIT_PRESETS = [ ]; export type ServiceType = - | "postgres" + | "application" + | "libsql" + | "mariadb" | "mongo" - | "redis" | "mysql" - | "mariadb" - | "application"; + | "postgres" + | "redis"; interface Props { id: string; @@ -105,27 +106,29 @@ type AddResources = z.infer; export const ShowResources = ({ id, type }: Props) => { const queryMap = { - postgres: () => - api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), - redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), - mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), - mariadb: () => - api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), application: () => api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), + 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 }), }; const { data, refetch } = queryMap[type] ? queryMap[type]() : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); const mutationMap = { - postgres: () => api.postgres.update.useMutation(), - redis: () => api.redis.update.useMutation(), - mysql: () => api.mysql.update.useMutation(), - mariadb: () => api.mariadb.update.useMutation(), application: () => api.application.update.useMutation(), + 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(), }; const { mutateAsync, isPending } = mutationMap[type] @@ -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, diff --git a/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx b/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx index 7c8dff068e..bfd4b99dcc 100644 --- a/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx @@ -34,13 +34,13 @@ interface Props { serviceId: string; serviceType: | "application" - | "postgres" - | "redis" + | "compose" + | "libsql" + | "mariadb" | "mongo" - | "redis" | "mysql" - | "mariadb" - | "compose"; + | "postgres" + | "redis"; refetch: () => void; children?: React.ReactNode; } diff --git a/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx b/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx index bc2329f069..e107897d2a 100644 --- a/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx @@ -29,23 +29,25 @@ export const ShowVolumes = ({ id, type }: Props) => { if (!canRead) return null; const queryMap = { - postgres: () => - api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), - redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), - mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), - mariadb: () => - api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), application: () => api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), - mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), compose: () => api.compose.one.useQuery({ composeId: id }, { enabled: !!id }), + libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), + mariadb: () => + api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), + mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), + postgres: () => + api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), + redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), }; const { data, refetch } = queryMap[type] ? queryMap[type]() : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); const { mutateAsync: deleteVolume, isPending: isRemoving } = api.mounts.remove.useMutation(); + return ( 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 9f31cc694d..882123efbd 100644 --- a/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx @@ -67,13 +67,13 @@ interface Props { refetch: () => void; serviceType: | "application" - | "postgres" - | "redis" + | "compose" + | "libsql" + | "mariadb" | "mongo" - | "redis" | "mysql" - | "mariadb" - | "compose"; + | "postgres" + | "redis"; } export const UpdateVolume = ({ @@ -253,7 +253,7 @@ export const UpdateVolume = ({ control={form.control} name="content" render={({ field }) => ( - + Content diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx index 4285f04c46..9f078f9d27 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 && (
diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 3cecef1ec7..ccf2564b06 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -1,3 +1,4 @@ +import copy from "copy-to-clipboard"; import { ChevronDown, ChevronUp, @@ -11,7 +12,6 @@ import { } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; -import copy from "copy-to-clipboard"; import { AlertBlock } from "@/components/shared/alert-block"; import { DateTooltip } from "@/components/shared/date-tooltip"; import { DialogAction } from "@/components/shared/dialog-action"; 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 0000000000..cd8254aa05 --- /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 00eb622727..655f9d1470 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 { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { DatabaseZap, Dices, RefreshCw } from "lucide-react"; +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; @@ -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,10 +253,13 @@ 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, isPending, domainId]); @@ -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 06428ae217..e971f9ab70 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"; @@ -74,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 { @@ -103,6 +146,16 @@ export const ShowDomains = ({ id, type }: Props) => { 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, @@ -140,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 (
@@ -151,13 +235,32 @@ export const ShowDomains = ({ id, type }: Props) => {
-
- {canCreateDomain && data && data?.length > 0 && ( - - - + {canCreateDomain && ( + + + + )} + )}
@@ -186,6 +289,122 @@ export const ShowDomains = ({ id, type }: Props) => { )} + ) : 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 && ( +
+
+ + +
+
+ )} +
) : (
{data?.map((item) => { @@ -341,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 89% rename from apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx rename to apps/dokploy/components/dashboard/application/environment/show-environment.tsx index 4f695ac88e..f5327818f4 100644 --- a/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx @@ -39,15 +39,16 @@ 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]() @@ -55,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, isPending } = mutationMap[type] ? mutationMap[type]() - : api.mongo.update.useMutation(); + : api.mongo.saveEnvironment.useMutation(); const form = useForm({ defaultValues: { @@ -87,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 () => { @@ -113,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" && !isPending) { + if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) { e.preventDefault(); form.handleSubmit(onSubmit)(); } diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx index fcfd81778d..fb5fc18a7d 100644 --- a/apps/dokploy/components/dashboard/application/environment/show.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show.tsx @@ -106,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" && !isPending) { + if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) { e.preventDefault(); form.handleSubmit(onSubmit)(); } 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 37a387bb5a..cb3190e0b1 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 @@ -55,7 +55,7 @@ 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, isPending } = 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 0000000000..ed2bd2675a --- /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 cbb6bce099..06b257766e 100644 --- a/apps/dokploy/components/dashboard/application/logs/show.tsx +++ b/apps/dokploy/components/dashboard/application/logs/show.tsx @@ -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/index.ts b/apps/dokploy/components/dashboard/application/patches/index.ts index 1854bd3e58..053e644b77 100644 --- a/apps/dokploy/components/dashboard/application/patches/index.ts +++ b/apps/dokploy/components/dashboard/application/patches/index.ts @@ -1,2 +1,2 @@ -export * from "./show-patches"; export * from "./patch-editor"; +export * from "./show-patches"; diff --git a/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx index d0df60098e..f3d60f27f8 100644 --- a/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx +++ b/apps/dokploy/components/dashboard/application/volume-backups/handle-volume-backups.tsx @@ -71,6 +71,7 @@ const formSchema = z "mongo", "mysql", "redis", + "libsql", ]), serviceName: z.string(), destinationId: z.string().min(1, "Destination required"), @@ -482,7 +483,7 @@ export const HandleVolumeBackups = ({ - Choose the volume to backup, if you dont see the + Choose the volume to backup. If you do not see the volume here, you can type the volume name manually @@ -517,7 +518,7 @@ export const HandleVolumeBackups = ({ - Choose the volume to backup, if you dont see the volume + Choose the volume to backup. If you do not see the volume here, you can type the volume name manually diff --git a/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx b/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx new file mode 100644 index 0000000000..7787d00e7a --- /dev/null +++ b/apps/dokploy/components/dashboard/compose/containers/show-compose-containers.tsx @@ -0,0 +1,290 @@ +import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react"; +import dynamic from "next/dynamic"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/utils/api"; +import { ShowContainerConfig } from "@/components/dashboard/docker/config/show-container-config"; +import { ShowContainerMounts } from "@/components/dashboard/docker/mounts/show-container-mounts"; +import { ShowContainerNetworks } from "@/components/dashboard/docker/networks/show-container-networks"; +import { DockerTerminalModal } from "@/components/dashboard/docker/terminal/docker-terminal-modal"; + +const DockerLogsId = dynamic( + () => + import("@/components/dashboard/docker/logs/docker-logs-id").then( + (e) => e.DockerLogsId, + ), + { + ssr: false, + }, +); + +interface Props { + appName: string; + serverId?: string; + appType: "stack" | "docker-compose"; +} + +export const ShowComposeContainers = ({ + appName, + appType, + serverId, +}: Props) => { + const { data, isPending, refetch } = + api.docker.getContainersByAppNameMatch.useQuery( + { + appName, + appType, + serverId, + }, + { + enabled: !!appName, + }, + ); + + return ( + + +
+ Containers + + Inspect each container in this compose and run basic lifecycle + actions. + +
+ +
+ + {isPending ? ( +
+ +
+ ) : !data || data.length === 0 ? ( +
+ + No containers found. Deploy the compose to see containers here. + +
+ ) : ( +
+ + + + Name + State + Status + Container ID + + + + + {data.map((container) => ( + refetch()} + /> + ))} + +
+
+ )} +
+
+ ); +}; + +interface ContainerRowProps { + container: { + containerId: string; + name: string; + state: string; + status: string; + }; + serverId?: string; + onActionComplete: () => void; +} + +const ContainerRow = ({ + container, + serverId, + onActionComplete, +}: ContainerRowProps) => { + const [logsOpen, setLogsOpen] = useState(false); + const [actionLoading, setActionLoading] = useState(null); + + const restartMutation = api.docker.restartContainer.useMutation(); + const startMutation = api.docker.startContainer.useMutation(); + const stopMutation = api.docker.stopContainer.useMutation(); + const killMutation = api.docker.killContainer.useMutation(); + + const handleAction = async ( + action: string, + mutationFn: typeof restartMutation, + ) => { + setActionLoading(action); + try { + await mutationFn.mutateAsync({ + containerId: container.containerId, + serverId, + }); + toast.success(`Container ${action} successfully`); + onActionComplete(); + } catch (error) { + toast.error( + `Failed to ${action} container: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } finally { + setActionLoading(null); + } + }; + + return ( + + {container.name} + + + {container.state} + + + {container.status} + + {container.containerId} + + + + + + + + + Actions + + e.preventDefault()} + > + View Logs + + + + + + + Terminal + + + handleAction("restart", restartMutation)} + > + Restart + + handleAction("start", startMutation)} + > + Start + + handleAction("stop", stopMutation)} + > + Stop + + handleAction("kill", killMutation)} + > + Kill + + + + + + View Logs + Logs for {container.name} + +
+ +
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/compose/delete-service.tsx b/apps/dokploy/components/dashboard/compose/delete-service.tsx index f4db6ad4a1..35fe01ff9f 100644 --- a/apps/dokploy/components/dashboard/compose/delete-service.tsx +++ b/apps/dokploy/components/dashboard/compose/delete-service.tsx @@ -57,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 }), @@ -72,6 +73,7 @@ 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(), @@ -98,6 +100,7 @@ export const DeleteService = ({ id, type }: Props) => { redisId: id || "", mysqlId: id || "", mariadbId: id || "", + libsqlId: id || "", applicationId: id || "", composeId: id || "", deleteVolumes, 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 28f958e3ea..e9d024fd36 100644 --- a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx +++ b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx @@ -95,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" && !isPending) { + if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) { e.preventDefault(); form.handleSubmit(onSubmit)(); } 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 c84a55bb3c..7878225a95 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 @@ -55,7 +55,7 @@ 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, isPending } = api.compose.update.useMutation(); 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 39f0254386..7ea71fc896 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 { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react"; +import { CheckIcon, ChevronsUpDown, Plus, X, HelpCircle } from "lucide-react"; import Link from "next/link"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; @@ -409,10 +409,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => { Watch Paths - -
- ? -
+ +

diff --git a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx index 159ab3485a..4c3067b156 100644 --- a/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx +++ b/apps/dokploy/components/dashboard/compose/logs/show-stack.tsx @@ -77,7 +77,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => { }, [option, services, containers]); const isLoading = option === "native" ? containersLoading : servicesLoading; - const containersLenght = + const containersLength = option === "native" ? containers?.length : services?.length; return ( @@ -152,7 +152,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => { )} - Containers ({containersLenght}) + Containers ({containersLength}) diff --git a/apps/dokploy/components/dashboard/database/backups/handle-backup.tsx b/apps/dokploy/components/dashboard/database/backups/handle-backup.tsx index 3ef31c26fa..26880e9b53 100644 --- a/apps/dokploy/components/dashboard/database/backups/handle-backup.tsx +++ b/apps/dokploy/components/dashboard/database/backups/handle-backup.tsx @@ -65,7 +65,13 @@ import { ScheduleFormField } from "../../application/schedules/handle-schedules" type CacheType = "cache" | "fetch"; -type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server"; +type DatabaseType = + | "postgres" + | "mariadb" + | "mysql" + | "mongo" + | "web-server" + | "libsql"; const Schema = z .object({ @@ -77,7 +83,7 @@ const Schema = z keepLatestCount: z.coerce.number().optional(), serviceName: z.string().nullable(), databaseType: z - .enum(["postgres", "mariadb", "mysql", "mongo", "web-server"]) + .enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"]) .optional(), backupType: z.enum(["database", "compose"]), metadata: z @@ -209,7 +215,12 @@ export const HandleBackup = ({ const form = useForm({ defaultValues: { - database: databaseType === "web-server" ? "dokploy" : "", + database: + databaseType === "web-server" + ? "dokploy" + : databaseType === "libsql" + ? "iku.db" + : "", destinationId: "", enabled: true, prefix: "/", @@ -246,7 +257,9 @@ export const HandleBackup = ({ ? backup?.database : databaseType === "web-server" ? "dokploy" - : "", + : databaseType === "libsql" + ? "iku.db" + : "", destinationId: backup?.destinationId ?? "", enabled: backup?.enabled ?? true, prefix: backup?.prefix ?? "/", @@ -281,11 +294,15 @@ export const HandleBackup = ({ ? { mongoId: id, } - : databaseType === "web-server" + : databaseType === "libsql" ? { - userId: id, + libsqlId: id, } - : undefined; + : databaseType === "web-server" + ? { + userId: id, + } + : undefined; await createBackup({ destinationId: data.destinationId, @@ -568,7 +585,10 @@ export const HandleBackup = ({ Database diff --git a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx index ba8e4caf57..7b212acb9e 100644 --- a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx +++ b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx @@ -88,7 +88,7 @@ const RestoreBackupSchema = z message: "Database name is required", }), databaseType: z - .enum(["postgres", "mariadb", "mysql", "mongo", "web-server"]) + .enum(["postgres", "mariadb", "mysql", "mongo", "web-server", "libsql"]) .optional(), backupType: z.enum(["database", "compose"]).default("database"), metadata: z @@ -211,7 +211,12 @@ export const RestoreBackup = ({ defaultValues: { destinationId: "", backupFile: "", - databaseName: databaseType === "web-server" ? "dokploy" : "", + databaseName: + databaseType === "web-server" + ? "dokploy" + : databaseType === "libsql" + ? "iku.db" + : "", databaseType: backupType === "compose" ? ("postgres" as DatabaseType) : databaseType, backupType: backupType, @@ -220,7 +225,7 @@ export const RestoreBackup = ({ resolver: zodResolver(RestoreBackupSchema), }); - const destionationId = form.watch("destinationId"); + const destinationId = form.watch("destinationId"); const currentDatabaseType = form.watch("databaseType"); const metadata = form.watch("metadata"); @@ -235,12 +240,12 @@ export const RestoreBackup = ({ const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery( { - destinationId: destionationId, + destinationId: destinationId, search: debouncedSearchTerm, serverId: serverId ?? "", }, { - enabled: isOpen && !!destionationId, + enabled: isOpen && !!destinationId, }, ); @@ -523,7 +528,10 @@ export const RestoreBackup = ({ diff --git a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx index 9aa1185482..ebffaccb37 100644 --- a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx +++ b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx @@ -53,14 +53,16 @@ export const ShowBackups = ({ const queryMap = backupType === "database" ? { - postgres: () => - api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), - mysql: () => - api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), mariadb: () => api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + mysql: () => + api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), + postgres: () => + api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), + libsql: () => + api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), "web-server": () => api.user.getBackups.useQuery(), } : { @@ -77,10 +79,11 @@ export const ShowBackups = ({ const mutationMap = backupType === "database" ? { - postgres: api.backup.manualBackupPostgres.useMutation(), - mysql: api.backup.manualBackupMySql.useMutation(), mariadb: api.backup.manualBackupMariadb.useMutation(), mongo: api.backup.manualBackupMongo.useMutation(), + mysql: api.backup.manualBackupMySql.useMutation(), + postgres: api.backup.manualBackupPostgres.useMutation(), + libsql: api.backup.manualBackupLibsql.useMutation(), "web-server": api.backup.manualBackupWebServer.useMutation(), } : { diff --git a/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx b/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx index e46b33a6a1..22b132f166 100644 --- a/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx +++ b/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx @@ -1,8 +1,8 @@ "use client"; import type { inferRouterOutputs } from "@trpc/server"; -import Link from "next/link"; 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 { 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 0000000000..267735eacf --- /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 59b9390087..8d8842ac09 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 && ( diff --git a/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx b/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx index 9d4f47c4af..bed5c6f5de 100644 --- a/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx @@ -103,7 +103,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) { > {" "}
- {/* 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 80a79eb2b6..01c68e49a1 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/mounts/show-container-mounts.tsx b/apps/dokploy/components/dashboard/docker/mounts/show-container-mounts.tsx new file mode 100644 index 0000000000..0c1832fc26 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/mounts/show-container-mounts.tsx @@ -0,0 +1,112 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { api } from "@/utils/api"; + +interface Props { + containerId: string; + serverId?: string; +} + +interface Mount { + Type: string; + Source: string; + Destination: string; + Mode: string; + RW: boolean; + Propagation: string; + Name?: string; + Driver?: string; +} + +export const ShowContainerMounts = ({ containerId, serverId }: Props) => { + const { data } = api.docker.getConfig.useQuery( + { + containerId, + serverId, + }, + { + enabled: !!containerId, + }, + ); + + const mounts: Mount[] = data?.Mounts ?? []; + + return ( + + + e.preventDefault()} + > + View Mounts + + + + + Container Mounts + + Volume and bind mounts for this container + + +
+ {mounts.length === 0 ? ( +
+ No mounts found for this container. +
+ ) : ( + + + + Type + Source + Destination + Mode + Read/Write + + + + {mounts.map((mount, index) => ( + + + {mount.Type} + + + {mount.Name || mount.Source} + + + {mount.Destination} + + + {mount.Mode || "-"} + + + + {mount.RW ? "RW" : "RO"} + + + + ))} + +
+ )} +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/docker/networks/show-container-networks.tsx b/apps/dokploy/components/dashboard/docker/networks/show-container-networks.tsx new file mode 100644 index 0000000000..12a015b3ad --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/networks/show-container-networks.tsx @@ -0,0 +1,119 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { api } from "@/utils/api"; + +interface Props { + containerId: string; + serverId?: string; +} + +interface Network { + IPAMConfig: unknown; + Links: unknown; + Aliases: string[] | null; + MacAddress: string; + NetworkID: string; + EndpointID: string; + Gateway: string; + IPAddress: string; + IPPrefixLen: number; + IPv6Gateway: string; + GlobalIPv6Address: string; + GlobalIPv6PrefixLen: number; + DriverOpts: unknown; +} + +export const ShowContainerNetworks = ({ containerId, serverId }: Props) => { + const { data } = api.docker.getConfig.useQuery( + { + containerId, + serverId, + }, + { + enabled: !!containerId, + }, + ); + + const networks: Record = + data?.NetworkSettings?.Networks ?? {}; + const entries = Object.entries(networks); + + return ( + + + e.preventDefault()} + > + View Networks + + + + + Container Networks + + Networks attached to this container + + +
+ {entries.length === 0 ? ( +
+ No networks found for this container. +
+ ) : ( + + + + Network + IP Address + Gateway + MAC Address + Aliases + + + + {entries.map(([name, network]) => ( + + + {name} + + + {network.IPAddress + ? `${network.IPAddress}/${network.IPPrefixLen}` + : "-"} + + + {network.Gateway || "-"} + + + {network.MacAddress || "-"} + + + {network.Aliases?.join(", ") || "-"} + + + ))} + +
+ )} +
+
+
+ ); +}; 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 0000000000..3b6cd98750 --- /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 79% rename from apps/dokploy/components/dashboard/docker/show/colums.tsx rename to apps/dokploy/components/dashboard/docker/show/columns.tsx index 74fe6819ed..fa06f4d705 100644 --- a/apps/dokploy/components/dashboard/docker/show/colums.tsx +++ b/apps/dokploy/components/dashboard/docker/show/columns.tsx @@ -10,7 +10,11 @@ import { } from "@/components/ui/dropdown-menu"; import { ShowContainerConfig } from "../config/show-container-config"; import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs"; +import { ShowContainerMounts } from "../mounts/show-container-mounts"; +import { ShowContainerNetworks } from "../networks/show-container-networks"; +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[] = [ @@ -121,12 +125,30 @@ export const columns: ColumnDef[] = [ containerId={container.containerId} serverId={container.serverId || ""} /> + + 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 69b0a0da2c..8a19566e84 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]; 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 0000000000..8838ac0947 --- /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/libsql/general/show-external-libsql-credentials.tsx b/apps/dokploy/components/dashboard/libsql/general/show-external-libsql-credentials.tsx new file mode 100644 index 0000000000..378d0d944c --- /dev/null +++ b/apps/dokploy/components/dashboard/libsql/general/show-external-libsql-credentials.tsx @@ -0,0 +1,251 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api } from "@/utils/api"; + +const DockerProviderSchema = z.object({ + externalPort: z.preprocess((a) => { + if (a === null || a === undefined || a === "") return null; + const parsed = Number.parseInt(String(a), 10); + return Number.isNaN(parsed) ? null : parsed; + }, z + .number() + .gte(0, "Range must be 0 - 65535") + .lte(65535, "Range must be 0 - 65535") + .nullable()), + externalGRPCPort: z.preprocess((a) => { + if (a === null || a === undefined || a === "") return null; + const parsed = Number.parseInt(String(a), 10); + return Number.isNaN(parsed) ? null : parsed; + }, z + .number() + .gte(0, "Range must be 0 - 65535") + .lte(65535, "Range must be 0 - 65535") + .nullable()), + externalAdminPort: z.preprocess((a) => { + if (a === null || a === undefined || a === "") return null; + const parsed = Number.parseInt(String(a), 10); + return Number.isNaN(parsed) ? null : parsed; + }, z + .number() + .gte(0, "Range must be 0 - 65535") + .lte(65535, "Range must be 0 - 65535") + .nullable()), +}); + +type DockerProvider = z.infer; + +interface Props { + libsqlId: string; +} +export const ShowExternalLibsqlCredentials = ({ libsqlId }: Props) => { + const { data: ip } = api.settings.getIp.useQuery(); + const { data, refetch } = api.libsql.one.useQuery({ libsqlId }); + const { mutateAsync, isPending } = api.libsql.saveExternalPorts.useMutation(); + const [connectionUrl, setConnectionUrl] = useState(""); + const [connectionGRPCUrl, setGRPCConnectionUrl] = useState(""); + const getIp = data?.server?.ipAddress || ip; + + const form = useForm({ + defaultValues: {}, + resolver: zodResolver(DockerProviderSchema), + }); + + useEffect(() => { + if (data) { + form.reset({ + externalPort: data.externalPort, + externalGRPCPort: data.externalGRPCPort, + externalAdminPort: data.externalAdminPort, + }); + } + }, [form.reset, data, form]); + + const onSubmit = async (values: DockerProvider) => { + await mutateAsync({ + externalPort: values.externalPort, + externalGRPCPort: values.externalGRPCPort, + externalAdminPort: values.externalAdminPort, + libsqlId, + }) + .then(async () => { + toast.success("External port/ports updated"); + await refetch(); + }) + .catch((error: Error) => { + toast.error(error?.message || "Error saving the external port/ports"); + }); + }; + + useEffect(() => { + const port = form.watch("externalPort") || data?.externalPort; + setConnectionUrl( + `http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`, + ); + + if (data?.sqldNode !== "replica") { + const grpcPort = form.watch("externalGRPCPort") || data?.externalGRPCPort; + setGRPCConnectionUrl( + `http://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${grpcPort}`, + ); + } + }, [ + data?.externalGRPCPort, + data?.databasePassword, + form, + data?.databaseUser, + getIp, + ]); + + return ( +
+ + + External Credentials + + In order to make the database reachable through the internet, you + must set a port and ensure that the port is not being used by + another application or database + + + + {!getIp && ( + + You need to set an IP address in your{" "} + + {data?.serverId + ? "Remote Servers -> Server -> Edit Server -> Update IP Address" + : "Web Server -> Server -> Update Server IP"} + {" "} + to fix the database url connection. + + )} +
+ +
+
+ ( + + External Port (Internet) + + + + + + )} + /> +
+
+ {!!data?.externalPort && ( +
+
+ + +
+
+ )} + +
+
+ ( + + External Admin Port (Internet) + + + + + + )} + /> +
+
+ + {data?.sqldNode !== "replica" && ( + <> +
+
+ ( + + External GRPC Port (Internet) + + + + + + )} + /> +
+
+ {!!data?.externalGRPCPort && ( +
+
+ + +
+
+ )} + + )} + +
+ +
+
+ +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/libsql/general/show-general-libsql.tsx b/apps/dokploy/components/dashboard/libsql/general/show-general-libsql.tsx new file mode 100644 index 0000000000..1727bb2b1a --- /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 0000000000..6c13502426 --- /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 0000000000..99455531ad --- /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 + +