diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml index 3554babb2..6de85fa27 100644 --- a/.github/workflows/pr-quality.yml +++ b/.github/workflows/pr-quality.yml @@ -16,7 +16,6 @@ jobs: steps: - uses: peakoss/anti-slop@v0 with: - max-failures: 4 blocked-commit-authors: "claude,copilot" require-description: true min-account-age: 5 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ad37899e6..4fa0dd358 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -99,7 +99,14 @@ pnpm run dokploy:build ## Docker -To build the docker image +To build the docker image first run commands to copy .env files + +```bash +cp apps/dokploy/.env.production.example .env.production +cp apps/dokploy/.env.production.example apps/dokploy/.env.production +``` + +then run build command ```bash pnpm run docker:build diff --git a/README.md b/README.md index 927e6ebc6..6a72f10d9 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies th Dokploy includes multiple features to make your life easier. - **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.). -- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, and Redis. +- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, libsql, and Redis. - **Backups**: Automate backups for databases to an external storage destination. - **Docker Compose**: Native support for Docker Compose to manage complex applications. - **Multi Node**: Scale applications to multiple nodes using Docker Swarm to manage the cluster. @@ -39,7 +39,7 @@ To get started, run the following command on a VPS: Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com). ```bash -curl -sSL https://dokploy.com/install.sh | sh +curl -sSL https://dokploy.com/install.sh | bash ``` For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com). diff --git a/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts index 097c916ea..6f843b8a8 100644 --- a/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts +++ b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts @@ -32,6 +32,8 @@ describe("Host rule format regression tests", () => { previewDeploymentId: "", internalPath: "/", stripPath: false, + customEntrypoint: null, + middlewares: null, }; describe("Host rule format validation", () => { diff --git a/apps/dokploy/__test__/compose/domain/labels.test.ts b/apps/dokploy/__test__/compose/domain/labels.test.ts index 9a75e0a84..ec8e9edc7 100644 --- a/apps/dokploy/__test__/compose/domain/labels.test.ts +++ b/apps/dokploy/__test__/compose/domain/labels.test.ts @@ -7,6 +7,7 @@ describe("createDomainLabels", () => { const baseDomain: Domain = { host: "example.com", port: 8080, + customEntrypoint: null, https: false, uniqueConfigKey: 1, customCertResolver: null, @@ -21,6 +22,7 @@ describe("createDomainLabels", () => { previewDeploymentId: "", internalPath: "/", stripPath: false, + middlewares: null, }; it("should create basic labels for web entrypoint", async () => { @@ -171,12 +173,12 @@ describe("createDomainLabels", () => { "websecure", ); - // Web entrypoint should have both middlewares with redirect first + // Web entrypoint with HTTPS should only have redirect expect(webLabels).toContain( - "traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,addprefix-test-app-1", + "traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file", ); - // Websecure should only have the addprefix middleware + // Websecure should have the addprefix middleware expect(websecureLabels).toContain( "traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1", ); @@ -208,9 +210,9 @@ describe("createDomainLabels", () => { "traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello", ); - // Should have middlewares in correct order: redirect, stripprefix, addprefix + // Web router with HTTPS should only have redirect expect(webLabels).toContain( - "traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,stripprefix-test-app-1,addprefix-test-app-1", + "traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file", ); }); @@ -240,4 +242,259 @@ describe("createDomainLabels", () => { "traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1", ); }); + + it("should add single custom middleware to router", async () => { + const customMiddlewareDomain = { + ...baseDomain, + middlewares: ["auth@file"], + }; + const labels = await createDomainLabels( + appName, + customMiddlewareDomain, + "web", + ); + + expect(labels).toContain( + "traefik.http.routers.test-app-1-web.middlewares=auth@file", + ); + }); + + it("should add multiple custom middlewares to router", async () => { + const customMiddlewareDomain = { + ...baseDomain, + middlewares: ["auth@file", "rate-limit@file"], + }; + const labels = await createDomainLabels( + appName, + customMiddlewareDomain, + "web", + ); + + expect(labels).toContain( + "traefik.http.routers.test-app-1-web.middlewares=auth@file,rate-limit@file", + ); + }); + + it("should only have redirect on web router when HTTPS is enabled with custom middlewares", async () => { + const combinedDomain = { + ...baseDomain, + https: true, + middlewares: ["auth@file"], + }; + const labels = await createDomainLabels(appName, combinedDomain, "web"); + + // Web router with HTTPS should only redirect, custom middlewares go on websecure + expect(labels).toContain( + "traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file", + ); + expect(labels).not.toContain("auth@file"); + }); + + it("should combine custom middlewares with stripPath middleware (no HTTPS)", async () => { + const combinedDomain = { + ...baseDomain, + path: "/api", + stripPath: true, + middlewares: ["auth@file"], + }; + const labels = await createDomainLabels(appName, combinedDomain, "web"); + + // stripprefix should come before custom middleware + expect(labels).toContain( + "traefik.http.routers.test-app-1-web.middlewares=stripprefix-test-app-1,auth@file", + ); + }); + + it("should only have redirect on web router even with all built-in middlewares and custom middlewares", async () => { + const fullDomain = { + ...baseDomain, + https: true, + path: "/api", + stripPath: true, + internalPath: "/hello", + middlewares: ["auth@file", "rate-limit@file"], + }; + const webLabels = await createDomainLabels(appName, fullDomain, "web"); + + // Web router with HTTPS should only redirect + expect(webLabels).toContain( + "traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file", + ); + // Middleware definitions should still be present (Traefik needs them registered) + expect(webLabels).toContain( + "traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api", + ); + expect(webLabels).toContain( + "traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello", + ); + // But they should NOT be attached to the router + expect(webLabels).not.toContain("stripprefix-test-app-1,"); + expect(webLabels).not.toContain("auth@file"); + expect(webLabels).not.toContain("rate-limit@file"); + }); + + it("should include custom middlewares on websecure entrypoint", async () => { + const customMiddlewareDomain = { + ...baseDomain, + https: true, + middlewares: ["auth@file"], + }; + const websecureLabels = await createDomainLabels( + appName, + customMiddlewareDomain, + "websecure", + ); + + // Websecure should have custom middleware but not redirect-to-https + expect(websecureLabels).toContain( + "traefik.http.routers.test-app-1-websecure.middlewares=auth@file", + ); + expect(websecureLabels).not.toContain("redirect-to-https"); + }); + + it("should NOT include custom middlewares on web router when HTTPS is enabled (only redirect)", async () => { + const domain = { + ...baseDomain, + https: true, + middlewares: ["rate-limit@file", "auth@file"], + }; + const webLabels = await createDomainLabels(appName, domain, "web"); + + // Web router with HTTPS should ONLY have redirect, not custom middlewares + expect(webLabels).toContain( + "traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file", + ); + expect(webLabels).not.toContain("rate-limit@file"); + expect(webLabels).not.toContain("auth@file"); + }); + + it("should create basic labels for custom entrypoint", async () => { + const labels = await createDomainLabels( + appName, + { ...baseDomain, customEntrypoint: "custom" }, + "custom", + ); + expect(labels).toEqual([ + "traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)", + "traefik.http.routers.test-app-1-custom.entrypoints=custom", + "traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080", + "traefik.http.routers.test-app-1-custom.service=test-app-1-custom", + ]); + }); + + it("should create https labels for custom entrypoint", async () => { + const labels = await createDomainLabels( + appName, + { + ...baseDomain, + https: true, + customEntrypoint: "custom", + certificateType: "letsencrypt", + }, + "custom", + ); + expect(labels).toEqual([ + "traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)", + "traefik.http.routers.test-app-1-custom.entrypoints=custom", + "traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080", + "traefik.http.routers.test-app-1-custom.service=test-app-1-custom", + "traefik.http.routers.test-app-1-custom.tls.certresolver=letsencrypt", + ]); + }); + + it("should add stripPath middleware for custom entrypoint", async () => { + const labels = await createDomainLabels( + appName, + { + ...baseDomain, + customEntrypoint: "custom", + path: "/api", + stripPath: true, + }, + "custom", + ); + + expect(labels).toContain( + "traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api", + ); + expect(labels).toContain( + "traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1", + ); + }); + + it("should add internalPath middleware for custom entrypoint", async () => { + const labels = await createDomainLabels( + appName, + { + ...baseDomain, + customEntrypoint: "custom", + internalPath: "/hello", + }, + "custom", + ); + + expect(labels).toContain( + "traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello", + ); + expect(labels).toContain( + "traefik.http.routers.test-app-1-custom.middlewares=addprefix-test-app-1", + ); + }); + + it("should add path prefix in rule for custom entrypoint", async () => { + const labels = await createDomainLabels( + appName, + { + ...baseDomain, + customEntrypoint: "custom", + path: "/api", + }, + "custom", + ); + + expect(labels).toContain( + "traefik.http.routers.test-app-1-custom.rule=Host(`example.com`) && PathPrefix(`/api`)", + ); + }); + + it("should combine all middlewares for custom entrypoint", async () => { + const labels = await createDomainLabels( + appName, + { + ...baseDomain, + customEntrypoint: "custom", + path: "/api", + stripPath: true, + internalPath: "/hello", + }, + "custom", + ); + + expect(labels).toContain( + "traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api", + ); + expect(labels).toContain( + "traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello", + ); + expect(labels).toContain( + "traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1,addprefix-test-app-1", + ); + }); + + it("should not add redirect-to-https for custom entrypoint even with https", async () => { + const labels = await createDomainLabels( + appName, + { + ...baseDomain, + customEntrypoint: "custom", + https: true, + certificateType: "letsencrypt", + }, + "custom", + ); + + const middlewareLabel = labels.find((l) => l.includes(".middlewares=")); + // Should not contain redirect-to-https since there's only one router + expect(middlewareLabel).toBeUndefined(); + }); }); diff --git a/apps/dokploy/__test__/compose/network/network-root.test.ts b/apps/dokploy/__test__/compose/network/network-root.test.ts index 0d3c841d4..1a6817913 100644 --- a/apps/dokploy/__test__/compose/network/network-root.test.ts +++ b/apps/dokploy/__test__/compose/network/network-root.test.ts @@ -292,7 +292,7 @@ networks: dokploy-network: `; -test("It shoudn't add suffix to dokploy-network", () => { +test("It shouldn't add suffix to dokploy-network", () => { const composeData = parse(composeFile7) as ComposeSpecification; const suffix = generateRandomHash(); diff --git a/apps/dokploy/__test__/compose/network/network-service.test.ts b/apps/dokploy/__test__/compose/network/network-service.test.ts index e07fa1546..073e61615 100644 --- a/apps/dokploy/__test__/compose/network/network-service.test.ts +++ b/apps/dokploy/__test__/compose/network/network-service.test.ts @@ -195,7 +195,7 @@ services: - dokploy-network `; -test("It shoudn't add suffix to dokploy-network in services", () => { +test("It shouldn't add suffix to dokploy-network in services", () => { const composeData = parse(composeFile7) as ComposeSpecification; const suffix = generateRandomHash(); @@ -241,10 +241,10 @@ services: dokploy-network: aliases: - apid - + `; -test("It shoudn't add suffix to dokploy-network in services multiples cases", () => { +test("It shouldn't add suffix to dokploy-network in services multiples cases", () => { const composeData = parse(composeFile8) as ComposeSpecification; const suffix = generateRandomHash(); diff --git a/apps/dokploy/__test__/compose/service/sevice-volumes-from.test.ts b/apps/dokploy/__test__/compose/service/service-volumes-from.test.ts similarity index 100% rename from apps/dokploy/__test__/compose/service/sevice-volumes-from.test.ts rename to apps/dokploy/__test__/compose/service/service-volumes-from.test.ts diff --git a/apps/dokploy/__test__/drop/drop.test.ts b/apps/dokploy/__test__/drop/drop.test.ts index 6e9940d6d..a524e8da0 100644 --- a/apps/dokploy/__test__/drop/drop.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.ts @@ -120,6 +120,7 @@ const baseApp: ApplicationNested = { environmentId: "", enabled: null, env: null, + icon: null, healthCheckSwarm: null, labelsSwarm: null, memoryLimit: null, diff --git a/apps/dokploy/__test__/env/stack-environment.test.ts b/apps/dokploy/__test__/env/stack-environment.test.ts index 13f5adb53..773adf3ed 100644 --- a/apps/dokploy/__test__/env/stack-environment.test.ts +++ b/apps/dokploy/__test__/env/stack-environment.test.ts @@ -1,4 +1,4 @@ -import { getEnviromentVariablesObject } from "@dokploy/server/index"; +import { getEnvironmentVariablesObject } from "@dokploy/server/index"; import { describe, expect, it } from "vitest"; const projectEnv = ` @@ -15,7 +15,7 @@ DATABASE_NAME=dev_database SECRET_KEY=env-secret-123 `; -describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => { +describe("getEnvironmentVariablesObject with environment variables (Stack compose)", () => { it("resolves environment variables correctly for Stack compose", () => { const serviceEnv = ` FOO=\${{environment.NODE_ENV}} @@ -23,7 +23,7 @@ BAR=\${{environment.API_URL}} BAZ=test `; - const result = getEnviromentVariablesObject( + const result = getEnvironmentVariablesObject( serviceEnv, projectEnv, environmentEnv, @@ -45,7 +45,7 @@ DATABASE_URL=\${{project.DATABASE_URL}} SERVICE_PORT=4000 `; - const result = getEnviromentVariablesObject( + const result = getEnvironmentVariablesObject( serviceEnv, projectEnv, environmentEnv, @@ -72,7 +72,7 @@ PASSWORD=secret123 DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb `; - const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv); + const result = getEnvironmentVariablesObject(serviceEnv, "", multiRefEnv); expect(result).toEqual({ DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb", @@ -85,7 +85,7 @@ UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}} `; expect(() => - getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv), + getEnvironmentVariablesObject(serviceWithUndefined, "", environmentEnv), ).toThrow("Invalid environment variable: environment.UNDEFINED_VAR"); }); @@ -95,7 +95,7 @@ NODE_ENV=production API_URL=\${{environment.API_URL}} `; - const result = getEnviromentVariablesObject( + const result = getEnvironmentVariablesObject( serviceOverrideEnv, "", environmentEnv, @@ -115,7 +115,7 @@ SERVICE_NAME=my-service COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}} `; - const result = getEnviromentVariablesObject( + const result = getEnvironmentVariablesObject( complexServiceEnv, projectEnv, environmentEnv, @@ -150,7 +150,7 @@ ENV_VAR=\${{environment.API_URL}} DB_NAME=\${{environment.DATABASE_NAME}} `; - const result = getEnviromentVariablesObject( + const result = getEnvironmentVariablesObject( serviceWithConflicts, conflictingProjectEnv, conflictingEnvironmentEnv, @@ -170,7 +170,7 @@ SERVICE_VAR=test PROJECT_VAR=\${{project.ENVIRONMENT}} `; - const result = getEnviromentVariablesObject( + const result = getEnvironmentVariablesObject( serviceWithEmpty, projectEnv, "", diff --git a/apps/dokploy/__test__/permissions/check-permission.test.ts b/apps/dokploy/__test__/permissions/check-permission.test.ts new file mode 100644 index 000000000..7f14e2d0e --- /dev/null +++ b/apps/dokploy/__test__/permissions/check-permission.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockMemberData = ( + role: string, + overrides: Record = {}, +) => ({ + id: "member-1", + role, + userId: "user-1", + organizationId: "org-1", + accessedProjects: [] as string[], + accessedServices: [] as string[], + accessedEnvironments: [] as string[], + canCreateProjects: overrides.canCreateProjects ?? false, + canDeleteProjects: overrides.canDeleteProjects ?? false, + canCreateServices: overrides.canCreateServices ?? false, + canDeleteServices: overrides.canDeleteServices ?? false, + canCreateEnvironments: overrides.canCreateEnvironments ?? false, + canDeleteEnvironments: overrides.canDeleteEnvironments ?? false, + canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false, + canAccessToDocker: overrides.canAccessToDocker ?? false, + canAccessToAPI: overrides.canAccessToAPI ?? false, + canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false, + canAccessToGitProviders: overrides.canAccessToGitProviders ?? false, + user: { id: "user-1", email: "test@test.com" }, +}); + +let memberToReturn: ReturnType = + mockMemberData("member"); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + member: { + findFirst: vi.fn(() => Promise.resolve(memberToReturn)), + findMany: vi.fn(() => Promise.resolve([])), + }, + organizationRole: { + findFirst: vi.fn(), + findMany: vi.fn(() => Promise.resolve([])), + }, + }, + }, +})); + +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: vi.fn(() => Promise.resolve(false)), +})); + +const { checkPermission } = await import("@dokploy/server/services/permission"); + +const ctx = { + user: { id: "user-1" }, + session: { activeOrganizationId: "org-1" }, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("static roles bypass enterprise resources", () => { + it("owner bypasses deployment.read", async () => { + memberToReturn = mockMemberData("owner"); + await expect( + checkPermission(ctx, { deployment: ["read"] }), + ).resolves.toBeUndefined(); + }); + + it("admin bypasses backup.create", async () => { + memberToReturn = mockMemberData("admin"); + await expect( + checkPermission(ctx, { backup: ["create"] }), + ).resolves.toBeUndefined(); + }); + + it("member bypasses schedule.delete", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { schedule: ["delete"] }), + ).resolves.toBeUndefined(); + }); + + it("member bypasses multiple enterprise permissions at once", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { + deployment: ["read"], + backup: ["create"], + domain: ["delete"], + }), + ).resolves.toBeUndefined(); + }); +}); + +describe("static roles validate free-tier resources", () => { + it("owner passes project.create", async () => { + memberToReturn = mockMemberData("owner"); + await expect( + checkPermission(ctx, { project: ["create"] }), + ).resolves.toBeUndefined(); + }); + + it("member fails project.create (no legacy override)", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { project: ["create"] }), + ).rejects.toThrow(); + }); + + it("member passes service.read", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { service: ["read"] }), + ).resolves.toBeUndefined(); + }); + + it("member fails service.create", async () => { + memberToReturn = mockMemberData("member"); + await expect( + checkPermission(ctx, { service: ["create"] }), + ).rejects.toThrow(); + }); +}); + +describe("legacy boolean overrides for member", () => { + it("member passes project.create with canCreateProjects=true", async () => { + memberToReturn = mockMemberData("member", { canCreateProjects: true }); + await expect( + checkPermission(ctx, { project: ["create"] }), + ).resolves.toBeUndefined(); + }); + + it("member passes docker.read with canAccessToDocker=true", async () => { + memberToReturn = mockMemberData("member", { canAccessToDocker: true }); + await expect( + checkPermission(ctx, { docker: ["read"] }), + ).resolves.toBeUndefined(); + }); + + it("member fails docker.read with canAccessToDocker=false", async () => { + memberToReturn = mockMemberData("member"); + await expect(checkPermission(ctx, { docker: ["read"] })).rejects.toThrow(); + }); +}); diff --git a/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts new file mode 100644 index 000000000..bb6f5f18b --- /dev/null +++ b/apps/dokploy/__test__/permissions/enterprise-only-resources.test.ts @@ -0,0 +1,79 @@ +import { + enterpriseOnlyResources, + statements, +} from "@dokploy/server/lib/access-control"; +import { describe, expect, it } from "vitest"; + +const FREE_TIER_RESOURCES = [ + "organization", + "member", + "invitation", + "team", + "ac", + "project", + "service", + "environment", + "docker", + "sshKeys", + "gitProviders", + "traefikFiles", + "api", +]; + +const ENTERPRISE_RESOURCES = [ + "volume", + "deployment", + "envVars", + "projectEnvVars", + "environmentEnvVars", + "server", + "registry", + "certificate", + "backup", + "volumeBackup", + "schedule", + "domain", + "destination", + "notification", + "tag", + "logs", + "monitoring", + "auditLog", +]; + +describe("enterpriseOnlyResources set", () => { + it("contains all enterprise resources", () => { + for (const resource of ENTERPRISE_RESOURCES) { + expect(enterpriseOnlyResources.has(resource)).toBe(true); + } + }); + + it("does NOT contain free-tier resources", () => { + for (const resource of FREE_TIER_RESOURCES) { + expect(enterpriseOnlyResources.has(resource)).toBe(false); + } + }); + + it("every resource in statements is either free or enterprise", () => { + const allResources = Object.keys(statements); + for (const resource of allResources) { + const isFree = FREE_TIER_RESOURCES.includes(resource); + const isEnterprise = enterpriseOnlyResources.has(resource); + expect(isFree || isEnterprise).toBe(true); + } + }); + + it("free and enterprise sets don't overlap", () => { + for (const resource of FREE_TIER_RESOURCES) { + expect(enterpriseOnlyResources.has(resource)).toBe(false); + } + }); + + it("all statement resources are accounted for", () => { + const allResources = Object.keys(statements); + const categorized = [...FREE_TIER_RESOURCES, ...ENTERPRISE_RESOURCES]; + for (const resource of allResources) { + expect(categorized).toContain(resource); + } + }); +}); diff --git a/apps/dokploy/__test__/permissions/resolve-permissions.test.ts b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts new file mode 100644 index 000000000..759c8dad8 --- /dev/null +++ b/apps/dokploy/__test__/permissions/resolve-permissions.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockMemberData = ( + role: string, + overrides: Record = {}, +) => ({ + id: "member-1", + role, + userId: "user-1", + organizationId: "org-1", + accessedProjects: [] as string[], + accessedServices: [] as string[], + accessedEnvironments: [] as string[], + canCreateProjects: overrides.canCreateProjects ?? false, + canDeleteProjects: overrides.canDeleteProjects ?? false, + canCreateServices: overrides.canCreateServices ?? false, + canDeleteServices: overrides.canDeleteServices ?? false, + canCreateEnvironments: overrides.canCreateEnvironments ?? false, + canDeleteEnvironments: overrides.canDeleteEnvironments ?? false, + canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false, + canAccessToDocker: overrides.canAccessToDocker ?? false, + canAccessToAPI: overrides.canAccessToAPI ?? false, + canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false, + canAccessToGitProviders: overrides.canAccessToGitProviders ?? false, + user: { id: "user-1", email: "test@test.com" }, +}); + +let memberToReturn: ReturnType = + mockMemberData("member"); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + member: { + findFirst: vi.fn(() => Promise.resolve(memberToReturn)), + findMany: vi.fn(() => Promise.resolve([])), + }, + organizationRole: { + findFirst: vi.fn(), + findMany: vi.fn(() => Promise.resolve([])), + }, + }, + }, +})); + +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: vi.fn(() => Promise.resolve(false)), +})); + +const { resolvePermissions } = await import( + "@dokploy/server/services/permission" +); +const { enterpriseOnlyResources, statements } = await import( + "@dokploy/server/lib/access-control" +); + +const ctx = { + user: { id: "user-1" }, + session: { activeOrganizationId: "org-1" }, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("enterprise resources for static roles", () => { + it("owner gets true for all enterprise resources", async () => { + memberToReturn = mockMemberData("owner"); + const perms = await resolvePermissions(ctx); + + for (const resource of enterpriseOnlyResources) { + const actions = statements[resource as keyof typeof statements]; + for (const action of actions) { + expect((perms as any)[resource][action]).toBe(true); + } + } + }); + + it("admin gets true for all enterprise resources", async () => { + memberToReturn = mockMemberData("admin"); + const perms = await resolvePermissions(ctx); + + for (const resource of enterpriseOnlyResources) { + const actions = statements[resource as keyof typeof statements]; + for (const action of actions) { + expect((perms as any)[resource][action]).toBe(true); + } + } + }); + + it("member gets true for service-level enterprise resources", async () => { + memberToReturn = mockMemberData("member"); + const perms = await resolvePermissions(ctx); + + expect(perms.deployment.read).toBe(true); + expect(perms.deployment.create).toBe(true); + expect(perms.domain.read).toBe(true); + expect(perms.backup.read).toBe(true); + expect(perms.logs.read).toBe(true); + expect(perms.monitoring.read).toBe(true); + }); + + it("member gets false for org-level enterprise resources", async () => { + memberToReturn = mockMemberData("member"); + const perms = await resolvePermissions(ctx); + + expect(perms.server.read).toBe(false); + expect(perms.registry.read).toBe(false); + expect(perms.certificate.read).toBe(false); + expect(perms.destination.read).toBe(false); + expect(perms.notification.read).toBe(false); + expect(perms.auditLog.read).toBe(false); + }); +}); + +describe("free-tier resources for member", () => { + it("member gets service.read=true", async () => { + memberToReturn = mockMemberData("member"); + const perms = await resolvePermissions(ctx); + expect(perms.service.read).toBe(true); + }); + + it("member gets project.create=false without legacy override", async () => { + memberToReturn = mockMemberData("member"); + const perms = await resolvePermissions(ctx); + expect(perms.project.create).toBe(false); + }); + + it("member gets project.create=true with canCreateProjects", async () => { + memberToReturn = mockMemberData("member", { canCreateProjects: true }); + const perms = await resolvePermissions(ctx); + expect(perms.project.create).toBe(true); + }); + + it("member gets docker.read=false without legacy override", async () => { + memberToReturn = mockMemberData("member"); + const perms = await resolvePermissions(ctx); + expect(perms.docker.read).toBe(false); + }); + + it("member gets docker.read=true with canAccessToDocker", async () => { + memberToReturn = mockMemberData("member", { canAccessToDocker: true }); + const perms = await resolvePermissions(ctx); + expect(perms.docker.read).toBe(true); + }); +}); + +describe("free-tier resources for owner", () => { + it("owner gets all free-tier permissions as true", async () => { + memberToReturn = mockMemberData("owner"); + const perms = await resolvePermissions(ctx); + expect(perms.project.create).toBe(true); + expect(perms.project.delete).toBe(true); + expect(perms.service.create).toBe(true); + expect(perms.service.read).toBe(true); + expect(perms.service.delete).toBe(true); + expect(perms.docker.read).toBe(true); + expect(perms.traefikFiles.read).toBe(true); + expect(perms.traefikFiles.write).toBe(true); + }); +}); diff --git a/apps/dokploy/__test__/permissions/service-access.test.ts b/apps/dokploy/__test__/permissions/service-access.test.ts new file mode 100644 index 000000000..b3786807d --- /dev/null +++ b/apps/dokploy/__test__/permissions/service-access.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockMemberData = ( + role: string, + accessedServices: string[] = [], + accessedProjects: string[] = [], +) => ({ + id: "member-1", + role, + userId: "user-1", + organizationId: "org-1", + accessedProjects, + accessedServices, + accessedEnvironments: [] as string[], + canCreateProjects: false, + canDeleteProjects: false, + canCreateServices: false, + canDeleteServices: false, + canCreateEnvironments: false, + canDeleteEnvironments: false, + canAccessToTraefikFiles: false, + canAccessToDocker: false, + canAccessToAPI: false, + canAccessToSSHKeys: false, + canAccessToGitProviders: false, + user: { id: "user-1", email: "test@test.com" }, +}); + +let memberToReturn: ReturnType = + mockMemberData("member"); + +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + member: { + findFirst: vi.fn(() => Promise.resolve(memberToReturn)), + findMany: vi.fn(() => Promise.resolve([])), + }, + organizationRole: { + findFirst: vi.fn(), + findMany: vi.fn(() => Promise.resolve([])), + }, + }, + }, +})); + +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: vi.fn(() => Promise.resolve(false)), +})); + +const { checkServicePermissionAndAccess, checkServiceAccess } = await import( + "@dokploy/server/services/permission" +); + +const ctx = { + user: { id: "user-1" }, + session: { activeOrganizationId: "org-1" }, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("checkServicePermissionAndAccess", () => { + it("owner bypasses accessedServices check", async () => { + memberToReturn = mockMemberData("owner", []); + await expect( + checkServicePermissionAndAccess(ctx, "service-123", { + deployment: ["read"], + }), + ).resolves.toBeUndefined(); + }); + + it("admin bypasses accessedServices check", async () => { + memberToReturn = mockMemberData("admin", []); + await expect( + checkServicePermissionAndAccess(ctx, "service-123", { + backup: ["create"], + }), + ).resolves.toBeUndefined(); + }); + + it("member with access to service passes", async () => { + memberToReturn = mockMemberData("member", ["service-123"]); + await expect( + checkServicePermissionAndAccess(ctx, "service-123", { + deployment: ["read"], + }), + ).resolves.toBeUndefined(); + }); + + it("member WITHOUT access to service fails", async () => { + memberToReturn = mockMemberData("member", ["other-service"]); + await expect( + checkServicePermissionAndAccess(ctx, "service-123", { + deployment: ["read"], + }), + ).rejects.toThrow("You don't have access to this service"); + }); + + it("member with empty accessedServices fails", async () => { + memberToReturn = mockMemberData("member", []); + await expect( + checkServicePermissionAndAccess(ctx, "service-123", { + domain: ["delete"], + }), + ).rejects.toThrow("You don't have access to this service"); + }); +}); + +describe("checkServiceAccess", () => { + it("member with service access passes read check", async () => { + memberToReturn = mockMemberData("member", ["app-1"]); + await expect( + checkServiceAccess(ctx, "app-1", "read"), + ).resolves.toBeUndefined(); + }); + + it("member without service access fails read check", async () => { + memberToReturn = mockMemberData("member", []); + await expect(checkServiceAccess(ctx, "app-1", "read")).rejects.toThrow( + "You don't have access to this service", + ); + }); + + it("owner bypasses all access checks", async () => { + memberToReturn = mockMemberData("owner", [], []); + await expect( + checkServiceAccess(ctx, "project-1", "create"), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts index fb448e3af..daf2dbe54 100644 --- a/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts +++ b/apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts @@ -57,7 +57,7 @@ const createApplication = ( env: null, }, replicas: 1, - stopGracePeriodSwarm: 0n, + stopGracePeriodSwarm: 0, ulimitsSwarm: null, serverId: "server-id", ...overrides, @@ -76,8 +76,8 @@ describe("mechanizeDockerContainer", () => { }); }); - it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => { - const application = createApplication({ stopGracePeriodSwarm: 0n }); + it("passes stopGracePeriodSwarm as a number and keeps zero values", async () => { + const application = createApplication({ stopGracePeriodSwarm: 0 }); await mechanizeDockerContainer(application); diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index b422279ca..e07f34ade 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -48,6 +48,20 @@ const baseSettings: WebServerSettings = { urlCallback: "", }, }, + whitelabelingConfig: { + appName: null, + appDescription: null, + logoUrl: null, + faviconUrl: null, + customCss: null, + loginLogoUrl: null, + supportUrl: null, + docsUrl: null, + errorPageTitle: null, + errorPageDescription: null, + metaTitle: null, + footerText: null, + }, cleanupCacheApplications: false, cleanupCacheOnCompose: false, cleanupCacheOnPreviews: false, diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 9121dc8a1..fe7d0ff9d 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,110 @@ test("CertificateType on websecure entrypoint", async () => { expect(router.tls?.certResolver).toBe("letsencrypt"); }); +test("Custom entrypoint on http domain", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, https: false, customEntrypoint: "custom" }, + "custom", + ); + + expect(router.entryPoints).toEqual(["custom"]); + expect(router.middlewares).not.toContain("redirect-to-https"); + expect(router.tls).toBeUndefined(); +}); + +test("Custom entrypoint on https domain", async () => { + const router = await createRouterConfig( + baseApp, + { + ...baseDomain, + https: true, + customEntrypoint: "custom", + certificateType: "letsencrypt", + }, + "custom", + ); + + expect(router.entryPoints).toEqual(["custom"]); + expect(router.middlewares).not.toContain("redirect-to-https"); + expect(router.tls?.certResolver).toBe("letsencrypt"); +}); + +test("Custom entrypoint with path includes PathPrefix in rule", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, customEntrypoint: "custom", path: "/api" }, + "custom", + ); + + expect(router.rule).toContain("PathPrefix(`/api`)"); + expect(router.entryPoints).toEqual(["custom"]); +}); + +test("Custom entrypoint with stripPath adds stripprefix middleware", async () => { + const router = await createRouterConfig( + baseApp, + { + ...baseDomain, + customEntrypoint: "custom", + path: "/api", + stripPath: true, + }, + "custom", + ); + + expect(router.middlewares).toContain("stripprefix--1"); + expect(router.entryPoints).toEqual(["custom"]); +}); + +test("Custom entrypoint with internalPath adds addprefix middleware", async () => { + const router = await createRouterConfig( + baseApp, + { + ...baseDomain, + customEntrypoint: "custom", + internalPath: "/hello", + }, + "custom", + ); + + expect(router.middlewares).toContain("addprefix--1"); + expect(router.entryPoints).toEqual(["custom"]); +}); + +test("Custom entrypoint with https and custom cert resolver", async () => { + const router = await createRouterConfig( + baseApp, + { + ...baseDomain, + https: true, + customEntrypoint: "custom", + certificateType: "custom", + customCertResolver: "myresolver", + }, + "custom", + ); + + expect(router.entryPoints).toEqual(["custom"]); + expect(router.tls?.certResolver).toBe("myresolver"); +}); + +test("Custom entrypoint without https should not have tls", async () => { + const router = await createRouterConfig( + baseApp, + { + ...baseDomain, + https: false, + customEntrypoint: "custom", + certificateType: "letsencrypt", + }, + "custom", + ); + + expect(router.entryPoints).toEqual(["custom"]); + expect(router.tls).toBeUndefined(); +}); + /** IDN/Punycode */ test("Internationalized domain name is converted to punycode", async () => { diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx index 4c6fc60c7..7d214716e 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx @@ -112,14 +112,21 @@ const menuItems: MenuItem[] = [ const hasStopGracePeriodSwarm = ( value: unknown, -): value is { stopGracePeriodSwarm: bigint | number | string | null } => +): value is { stopGracePeriodSwarm: number | string | null } => typeof value === "object" && value !== null && "stopGracePeriodSwarm" in value; interface Props { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: + | "application" + | "libsql" + | "mariadb" + | "mongo" + | "mysql" + | "postgres" + | "redis"; } export const AddSwarmSettings = ({ id, type }: Props) => { diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx index 8de863957..95f849480 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx @@ -37,27 +37,27 @@ import { AddSwarmSettings } from "./modify-swarm-settings"; interface Props { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: "application" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis"; } -const AddRedirectchema = z.object({ +const AddRedirectSchema = z.object({ replicas: z.number().min(1, "Replicas must be at least 1"), registryId: z.string().optional(), }); -type AddCommand = z.infer; +type AddCommand = z.infer; export const ShowClusterSettings = ({ id, type }: Props) => { const queryMap = { + application: () => + api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), + mariadb: () => + api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), + mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), postgres: () => api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), - mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), - mariadb: () => - api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), - application: () => - api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), - mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), }; const { data, refetch } = queryMap[type] ? queryMap[type]() @@ -65,12 +65,13 @@ export const ShowClusterSettings = ({ id, type }: Props) => { const { data: registries } = api.registry.all.useQuery(); const mutationMap = { + application: () => api.application.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), + mariadb: () => api.mariadb.update.useMutation(), + mongo: () => api.mongo.update.useMutation(), + mysql: () => api.mysql.update.useMutation(), postgres: () => api.postgres.update.useMutation(), redis: () => api.redis.update.useMutation(), - mysql: () => api.mysql.update.useMutation(), - mariadb: () => api.mariadb.update.useMutation(), - application: () => api.application.update.useMutation(), - mongo: () => api.mongo.update.useMutation(), }; const { mutateAsync, 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 6d95634be..6ea18c653 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/endpoint-spec-form.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/endpoint-spec-form.tsx @@ -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 f62037fca..06c8eb94a 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/health-check-form.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/health-check-form.tsx @@ -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 41ce741ae..02a480a03 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/labels-form.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/labels-form.tsx @@ -29,7 +29,14 @@ export const labelsFormSchema = z.object({ interface LabelsFormProps { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: + | "postgres" + | "mariadb" + | "mongo" + | "mysql" + | "redis" + | "application" + | "libsql"; } export const LabelsForm = ({ id, type }: LabelsFormProps) => { @@ -45,6 +52,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => { application: () => api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), }; const { data, refetch } = queryMap[type] ? queryMap[type]() @@ -57,6 +65,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => { mariadb: () => api.mariadb.update.useMutation(), application: () => api.application.update.useMutation(), mongo: () => api.mongo.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), }; const { mutateAsync } = mutationMap[type] @@ -112,6 +121,7 @@ export const LabelsForm = ({ id, type }: LabelsFormProps) => { mysqlId: id || "", mariadbId: id || "", mongoId: id || "", + libsqlId: id || "", labelsSwarm: labelsToSend, }); diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/mode-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/mode-form.tsx index a6885a7e4..bd2eca18e 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/mode-form.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/mode-form.tsx @@ -23,7 +23,14 @@ import { api } from "@/utils/api"; interface ModeFormProps { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: + | "postgres" + | "mariadb" + | "mongo" + | "mysql" + | "redis" + | "application" + | "libsql"; } export const ModeForm = ({ id, type }: ModeFormProps) => { @@ -39,6 +46,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => { application: () => api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), }; const { data, refetch } = queryMap[type] ? queryMap[type]() @@ -51,6 +59,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => { mariadb: () => api.mariadb.update.useMutation(), application: () => api.application.update.useMutation(), mongo: () => api.mongo.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), }; const { mutateAsync } = mutationMap[type] @@ -95,6 +104,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => { mysqlId: id || "", mariadbId: id || "", mongoId: id || "", + libsqlId: id || "", modeSwarm: null, }); toast.success("Mode updated successfully"); @@ -122,6 +132,7 @@ export const ModeForm = ({ id, type }: ModeFormProps) => { mysqlId: id || "", mariadbId: id || "", mongoId: id || "", + libsqlId: id || "", modeSwarm: modeData, }); diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/network-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/network-form.tsx index 7d6ebbaf3..269d6f784 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/network-form.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/network-form.tsx @@ -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 b4091aac0..a4a650020 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/placement-form.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/placement-form.tsx @@ -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 db7be5629..4aba01f03 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/restart-policy-form.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/restart-policy-form.tsx @@ -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 528b9d1cc..081825e64 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/rollback-config-form.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/rollback-config-form.tsx @@ -34,7 +34,14 @@ export const rollbackConfigFormSchema = z.object({ interface RollbackConfigFormProps { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: + | "postgres" + | "mariadb" + | "mongo" + | "mysql" + | "redis" + | "application" + | "libsql"; } export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => { @@ -50,6 +57,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => { application: () => api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), }; const { data, refetch } = queryMap[type] ? queryMap[type]() @@ -62,6 +70,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => { mariadb: () => api.mariadb.update.useMutation(), application: () => api.application.update.useMutation(), mongo: () => api.mongo.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), }; const { mutateAsync } = mutationMap[type] @@ -103,6 +112,7 @@ export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => { mysqlId: id || "", mariadbId: id || "", mongoId: id || "", + libsqlId: id || "", rollbackConfigSwarm: (hasAnyValue ? formData : null) as any, }); diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/stop-grace-period-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/stop-grace-period-form.tsx index a324da31b..ebc93a388 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/stop-grace-period-form.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/stop-grace-period-form.tsx @@ -16,14 +16,21 @@ import { api } from "@/utils/api"; const hasStopGracePeriodSwarm = ( value: unknown, -): value is { stopGracePeriodSwarm: bigint | number | string | null } => +): value is { stopGracePeriodSwarm: number | string | null } => typeof value === "object" && value !== null && "stopGracePeriodSwarm" in value; interface StopGracePeriodFormProps { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: + | "postgres" + | "mariadb" + | "mongo" + | "mysql" + | "redis" + | "application" + | "libsql"; } export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => { @@ -39,6 +46,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => { application: () => api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), }; const { data, refetch } = queryMap[type] ? queryMap[type]() @@ -51,6 +59,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => { mariadb: () => api.mariadb.update.useMutation(), application: () => api.application.update.useMutation(), mongo: () => api.mongo.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), }; const { mutateAsync } = mutationMap[type] @@ -59,7 +68,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => { const form = useForm({ defaultValues: { - value: null as bigint | null, + value: null as number | null, }, }); @@ -67,11 +76,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => { if (hasStopGracePeriodSwarm(data)) { const value = data.stopGracePeriodSwarm; const normalizedValue = - value === null || value === undefined - ? null - : typeof value === "bigint" - ? value - : BigInt(value); + value === null || value === undefined ? null : Number(value); form.reset({ value: normalizedValue, }); @@ -88,6 +93,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => { mysqlId: id || "", mariadbId: id || "", mongoId: id || "", + libsqlId: id || "", stopGracePeriodSwarm: formData.value, }); @@ -126,7 +132,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => { } onChange={(e) => field.onChange( - e.target.value ? BigInt(e.target.value) : null, + e.target.value ? Number(e.target.value) : null, ) } /> diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/update-config-form.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/update-config-form.tsx index af2d826db..ef9fe34bb 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/update-config-form.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/swarm-forms/update-config-form.tsx @@ -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 172c042f1..683e0ebba 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 3b30155bf..fa2bda629 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" - | "mongo" - | "redis" - | "mysql" + | "application" + | "libsql" | "mariadb" - | "application"; + | "mongo" + | "mysql" + | "postgres" + | "redis"; interface Props { id: string; @@ -105,27 +106,29 @@ type AddResources = z.infer; export const ShowResources = ({ id, type }: Props) => { const queryMap = { + application: () => + api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), + libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), + mariadb: () => + api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), + mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), postgres: () => api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), - mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), - mariadb: () => - api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), - application: () => - api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), - mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), }; const { data, refetch } = queryMap[type] ? queryMap[type]() : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); const mutationMap = { + application: () => api.application.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), + mariadb: () => api.mariadb.update.useMutation(), + mongo: () => api.mongo.update.useMutation(), + mysql: () => api.mysql.update.useMutation(), postgres: () => api.postgres.update.useMutation(), redis: () => api.redis.update.useMutation(), - mysql: () => api.mysql.update.useMutation(), - mariadb: () => api.mariadb.update.useMutation(), - application: () => api.application.update.useMutation(), - mongo: () => api.mongo.update.useMutation(), }; const { mutateAsync, 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/traefik/show-traefik-config.tsx b/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx index 5d8943197..94efbc285 100644 --- a/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/traefik/show-traefik-config.tsx @@ -15,13 +15,17 @@ interface Props { } export const ShowTraefikConfig = ({ applicationId }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canRead = permissions?.traefikFiles.read ?? false; const { data, isPending } = api.application.readTraefikConfig.useQuery( { applicationId, }, - { enabled: !!applicationId }, + { enabled: !!applicationId && canRead }, ); + if (!canRead) return null; + return ( diff --git a/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx b/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx index a8ec9053f..b3646803c 100644 --- a/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/traefik/update-traefik-config.tsx @@ -60,6 +60,8 @@ export const validateAndFormatYAML = (yamlText: string) => { }; export const UpdateTraefikConfig = ({ applicationId }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canWrite = permissions?.traefikFiles.write ?? false; const [open, setOpen] = useState(false); const [skipYamlValidation, setSkipYamlValidation] = useState(false); const { data, refetch } = api.application.readTraefikConfig.useQuery( @@ -125,9 +127,11 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => { } }} > - - - + {canWrite && ( + + + + )} Update traefik config 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 7c8dff068..bfd4b99dc 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" - | "mongo" - | "redis" - | "mysql" + | "compose" + | "libsql" | "mariadb" - | "compose"; + | "mongo" + | "mysql" + | "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 92b259140..e107897d2 100644 --- a/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx @@ -21,24 +21,33 @@ interface Props { } export const ShowVolumes = ({ id, type }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canRead = permissions?.volume.read ?? false; + const canCreate = permissions?.volume.create ?? false; + const canDelete = permissions?.volume.delete ?? false; + + if (!canRead) return null; + const queryMap = { + application: () => + api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), + compose: () => + api.compose.one.useQuery({ composeId: id }, { enabled: !!id }), + libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), + mariadb: () => + api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), + mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), postgres: () => 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 }), }; 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 ( @@ -50,7 +59,7 @@ export const ShowVolumes = ({ id, type }: Props) => { - {data && data?.mounts.length > 0 && ( + {canCreate && data && data?.mounts.length > 0 && ( Add Volume @@ -63,9 +72,11 @@ export const ShowVolumes = ({ id, type }: Props) => { No volumes/mounts configured - - Add Volume - + {canCreate && ( + + Add Volume + + )} ) : (
@@ -130,38 +141,42 @@ export const ShowVolumes = ({ id, type }: Props) => {
- - { - await deleteVolume({ - mountId: mount.mountId, - }) - .then(() => { - refetch(); - toast.success("Volume deleted successfully"); + {canCreate && ( + + )} + {canDelete && ( + { + await deleteVolume({ + mountId: mount.mountId, }) - .catch(() => { - toast.error("Error deleting volume"); - }); - }} - > - - + + + )}
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 9f31cc694..882123efb 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" - | "mongo" - | "redis" - | "mysql" + | "compose" + | "libsql" | "mariadb" - | "compose"; + | "mongo" + | "mysql" + | "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-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 61841e294..ccf2564b0 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -1,7 +1,9 @@ +import copy from "copy-to-clipboard"; import { ChevronDown, ChevronUp, Clock, + Copy, Loader2, RefreshCcw, RocketIcon, @@ -97,6 +99,12 @@ export const ShowDeployments = ({ new Set(), ); + const webhookUrl = useMemo( + () => + `${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`, + [url, refreshToken, type], + ); + const MAX_DESCRIPTION_LENGTH = 200; const truncateDescription = (description: string): string => { @@ -224,11 +232,27 @@ export const ShowDeployments = ({
Webhook URL:
- - {`${url}/api/deploy${ - type === "compose" ? "/compose" : "" - }/${refreshToken}`} - + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + copy(webhookUrl); + toast.success("Copied to clipboard."); + } + }} + onClick={() => { + copy(webhookUrl); + toast.success("Copied to clipboard."); + }} + > + {webhookUrl} + + {(type === "application" || type === "compose") && ( )} diff --git a/apps/dokploy/components/dashboard/application/domains/columns.tsx b/apps/dokploy/components/dashboard/application/domains/columns.tsx new file mode 100644 index 000000000..cd8254aa0 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/domains/columns.tsx @@ -0,0 +1,303 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import { + ArrowUpDown, + CheckCircle2, + ExternalLink, + Loader2, + PenBoxIcon, + RefreshCw, + Server, + Trash2, + XCircle, +} from "lucide-react"; +import Link from "next/link"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { RouterOutputs } from "@/utils/api"; +import type { ValidationStates } from "./show-domains"; +import { AddDomain } from "./handle-domain"; +import { DnsHelperModal } from "./dns-helper-modal"; + +export type Domain = + | RouterOutputs["domain"]["byApplicationId"][0] + | RouterOutputs["domain"]["byComposeId"][0]; + +interface ColumnsProps { + id: string; + type: "application" | "compose"; + validationStates: ValidationStates; + handleValidateDomain: (host: string) => Promise; + handleDeleteDomain: (domainId: string) => Promise; + isDeleting: boolean; + serverIp?: string; + canCreateDomain: boolean; + canDeleteDomain: boolean; +} + +export const createColumns = ({ + id, + type, + validationStates, + handleValidateDomain, + handleDeleteDomain, + isDeleting, + serverIp, + canCreateDomain, + canDeleteDomain, +}: ColumnsProps): ColumnDef[] => [ + ...(type === "compose" + ? [ + { + accessorKey: "serviceName", + header: "Service", + cell: ({ row }: { row: { getValue: (key: string) => unknown } }) => { + const serviceName = row.getValue("serviceName") as string | null; + if (!serviceName) return null; + return ( + + + {serviceName} + + ); + }, + } satisfies ColumnDef, + ] + : []), + { + accessorKey: "host", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const domain = row.original; + return ( + + {domain.host} + + + ); + }, + }, + { + accessorKey: "path", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const path = row.getValue("path") as string; + return
{path || "/"}
; + }, + }, + { + accessorKey: "port", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const port = row.getValue("port") as number; + return {port}; + }, + }, + { + accessorKey: "customEntrypoint", + header: "Entrypoint", + cell: ({ row }) => { + const entrypoint = row.getValue("customEntrypoint") as string | null; + if (!entrypoint) return -; + return
{entrypoint}
; + }, + }, + { + accessorKey: "https", + header: "Protocol", + cell: ({ row }) => { + const https = row.getValue("https") as boolean; + return ( + + {https ? "HTTPS" : "HTTP"} + + ); + }, + }, + { + id: "certificate", + header: "Certificate", + cell: ({ row }) => { + const domain = row.original; + const validationState = validationStates[domain.host]; + + return ( +
+ {domain.certificateType && ( + + {domain.certificateType} + + )} + {!domain.host.includes("traefik.me") && ( + + + + handleValidateDomain(domain.host)} + > + {validationState?.isLoading ? ( + <> + + Checking... + + ) : validationState?.isValid ? ( + <> + + {validationState.message && validationState.cdnProvider + ? `${validationState.cdnProvider}` + : "Valid"} + + ) : validationState?.error ? ( + <> + + Invalid + + ) : ( + <> + + Validate + + )} + + + + {validationState?.error ? ( +
+

Error:

+

{validationState.error}

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

Traefik middleware reference

+
+
+
+ ))} + diff --git a/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx similarity index 87% rename from apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx rename to apps/dokploy/components/dashboard/application/environment/show-environment.tsx index 8ff0f6a63..1a7d78ee5 100644 --- a/apps/dokploy/components/dashboard/application/environment/show-enviroment.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx @@ -36,16 +36,19 @@ interface Props { } export const ShowEnvironment = ({ id, type }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canWrite = permissions?.envVars.write ?? false; const queryMap = { - postgres: () => - api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), - redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), - mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), + compose: () => + api.compose.one.useQuery({ composeId: id }, { enabled: !!id }), + libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), mariadb: () => api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), - compose: () => - api.compose.one.useQuery({ composeId: id }, { enabled: !!id }), + mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), + postgres: () => + api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), + redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), }; const { data, refetch } = queryMap[type] ? queryMap[type]() @@ -53,12 +56,13 @@ export const ShowEnvironment = ({ id, type }: Props) => { const [isEnvVisible, setIsEnvVisible] = useState(true); const mutationMap = { - postgres: () => api.postgres.update.useMutation(), - redis: () => api.redis.update.useMutation(), - mysql: () => api.mysql.update.useMutation(), + compose: () => api.compose.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), mariadb: () => api.mariadb.update.useMutation(), mongo: () => api.mongo.update.useMutation(), - compose: () => api.compose.update.useMutation(), + mysql: () => api.mysql.update.useMutation(), + postgres: () => api.postgres.update.useMutation(), + redis: () => api.redis.update.useMutation(), }; const { mutateAsync, isPending } = mutationMap[type] ? mutationMap[type]() @@ -85,12 +89,13 @@ export const ShowEnvironment = ({ id, type }: Props) => { const onSubmit = async (formData: EnvironmentSchema) => { mutateAsync({ + composeId: id || "", + libsqlId: id || "", + mariadbId: id || "", mongoId: id || "", + mysqlId: id || "", postgresId: id || "", redisId: id || "", - mysqlId: id || "", - mariadbId: id || "", - composeId: id || "", env: formData.environment, }) .then(async () => { @@ -111,7 +116,7 @@ export const ShowEnvironment = ({ id, type }: Props) => { // Add keyboard shortcut for Ctrl+S/Cmd+S useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) { + if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) { e.preventDefault(); form.handleSubmit(onSubmit)(); } @@ -185,25 +190,27 @@ PORT=3000 )} /> -
- {hasChanges && ( + {canWrite && ( +
+ {hasChanges && ( + + )} - )} - -
+
+ )} diff --git a/apps/dokploy/components/dashboard/application/environment/show.tsx b/apps/dokploy/components/dashboard/application/environment/show.tsx index 04b6bc4c9..fb5fc18a7 100644 --- a/apps/dokploy/components/dashboard/application/environment/show.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show.tsx @@ -31,6 +31,8 @@ interface Props { } export const ShowEnvironment = ({ applicationId }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canWrite = permissions?.envVars.write ?? false; const { mutateAsync, isPending } = api.application.saveEnvironment.useMutation(); @@ -104,7 +106,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => { // Add keyboard shortcut for Ctrl+S/Cmd+S useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) { + if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) { e.preventDefault(); form.handleSubmit(onSubmit)(); } @@ -201,27 +203,30 @@ export const ShowEnvironment = ({ applicationId }: Props) => { )} /> )} -
- {hasChanges && ( - + )} + - )} - -
+
+ )}
diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx index d10925eff..a4fab46d9 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx @@ -1,5 +1,5 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; +import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react"; import Link from "next/link"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; @@ -416,10 +416,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => { Watch Paths - -
- ? -
+ +

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 624adeb55..37a387bb5 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx @@ -1,5 +1,5 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { KeyRoundIcon, LockIcon, X } from "lucide-react"; +import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/router"; import { useEffect } from "react"; @@ -228,10 +228,8 @@ export const SaveGitProvider = ({ applicationId }: Props) => { Watch Paths - -

- ? -
+ +

diff --git a/apps/dokploy/components/dashboard/application/general/show.tsx b/apps/dokploy/components/dashboard/application/general/show.tsx index ee42caa5e..01fc9e84a 100644 --- a/apps/dokploy/components/dashboard/application/general/show.tsx +++ b/apps/dokploy/components/dashboard/application/general/show.tsx @@ -30,6 +30,9 @@ interface Props { export const ShowGeneralApplication = ({ applicationId }: Props) => { const router = useRouter(); + const { data: permissions } = api.user.getPermissions.useQuery(); + const canDeploy = permissions?.deployment.create ?? false; + const canUpdateService = permissions?.service.create ?? false; const { data, refetch } = api.application.one.useQuery( { applicationId, @@ -57,128 +60,135 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => { - { - await deploy({ - applicationId: applicationId, - }) - .then(() => { - toast.success("Application deployed successfully"); - refetch(); - router.push( - `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`, - ); + {canDeploy && ( + { + await deploy({ + applicationId: applicationId, }) - .catch(() => { - toast.error("Error deploying application"); - }); - }} - > - - - { - await reload({ - applicationId: applicationId, - appName: data?.appName || "", - }) - .then(() => { - toast.success("Application reloaded successfully"); - refetch(); + + + )} + {canDeploy && ( + { + await reload({ + applicationId: applicationId, + appName: data?.appName || "", }) - .catch(() => { - toast.error("Error reloading application"); - }); - }} - > - - - { - await redeploy({ - applicationId: applicationId, - }) - .then(() => { - toast.success("Application rebuilt successfully"); - refetch(); + + + )} + {canDeploy && ( + { + await redeploy({ + applicationId: applicationId, }) - .catch(() => { - toast.error("Error rebuilding application"); - }); - }} - > - - + + + )} - {data?.applicationStatus === "idle" ? ( + {canDeploy && data?.applicationStatus === "idle" ? ( { - ) : ( + ) : canDeploy ? ( { - )} + ) : null} { Open Terminal -

- Autodeploy - { - await update({ - applicationId, - autoDeploy: enabled, - }) - .then(async () => { - toast.success("Auto Deploy Updated"); - await refetch(); + {canUpdateService && ( +
+ Autodeploy + { + await update({ + applicationId, + autoDeploy: enabled, }) - .catch(() => { - toast.error("Error updating Auto Deploy"); - }); - }} - className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" - /> -
+ .then(async () => { + toast.success("Auto Deploy Updated"); + await refetch(); + }) + .catch(() => { + toast.error("Error updating Auto Deploy"); + }); + }} + className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" + /> +
+ )} -
- Clean Cache - { - await update({ - applicationId, - cleanCache: enabled, - }) - .then(async () => { - toast.success("Clean Cache Updated"); - await refetch(); + {canUpdateService && ( +
+ Clean Cache + { + await update({ + applicationId, + cleanCache: enabled, }) - .catch(() => { - toast.error("Error updating Clean Cache"); - }); - }} - className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" - /> -
+ .then(async () => { + toast.success("Clean Cache Updated"); + await refetch(); + }) + .catch(() => { + toast.error("Error updating Clean Cache"); + }); + }} + className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" + /> +
+ )}
diff --git a/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx b/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx new file mode 100644 index 000000000..ed2bd2675 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx @@ -0,0 +1,277 @@ +import DOMPurify from "dompurify"; +import { GlobeIcon, Pencil, Search, X } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Dropzone } from "@/components/ui/dropzone"; +import { Input } from "@/components/ui/input"; +import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons"; +import { api } from "@/utils/api"; + +interface ShowIconSettingsProps { + applicationId: string; + icon?: string | null; +} + +const svgToDataUrl = (icon: BundledIcon): string => { + const svg = ``; + return `data:image/svg+xml;base64,${btoa(svg)}`; +}; + +export const ShowIconSettings = ({ + applicationId, + icon, +}: ShowIconSettingsProps) => { + const [open, setOpen] = useState(false); + const [iconSearchQuery, setIconSearchQuery] = useState(""); + const [iconsToShow, setIconsToShow] = useState(24); + + const filteredIcons = useMemo(() => { + if (!iconSearchQuery) return bundledIcons; + const q = iconSearchQuery.toLowerCase(); + return bundledIcons.filter( + (i) => + i.title.toLowerCase().includes(q) || i.slug.toLowerCase().includes(q), + ); + }, [iconSearchQuery]); + + const displayedIcons = filteredIcons.slice(0, iconsToShow); + const hasMoreIcons = filteredIcons.length > iconsToShow; + + const utils = api.useUtils(); + const { mutateAsync: updateApplication } = + api.application.update.useMutation(); + + useEffect(() => { + if (open) { + setIconSearchQuery(""); + setIconsToShow(24); + } + }, [open]); + + const handleIconSelect = async (selectedIcon: BundledIcon) => { + try { + const dataUrl = svgToDataUrl(selectedIcon); + await updateApplication({ + applicationId, + icon: dataUrl, + }); + toast.success("Icon saved successfully"); + await utils.application.one.invalidate({ applicationId }); + setOpen(false); + } catch (_error) { + toast.error("Error saving icon"); + } + }; + + const handleRemoveIcon = async () => { + try { + await updateApplication({ + applicationId, + icon: null, + }); + toast.success("Icon removed"); + await utils.application.one.invalidate({ applicationId }); + } catch (_error) { + toast.error("Error removing icon"); + } + }; + + const sanitizeSvg = (svgContent: string): string | null => { + const clean = DOMPurify.sanitize(svgContent, { + USE_PROFILES: { svg: true, svgFilters: true }, + ADD_TAGS: ["use"], + }); + if (!clean) return null; + return `data:image/svg+xml;base64,${btoa(clean)}`; + }; + + const handleFileUpload = async (files: FileList | null) => { + if (!files || files.length === 0) return; + const file = files[0]; + if (!file) return; + + const allowedTypes = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/svg+xml", + ]; + const fileExtension = file.name.split(".").pop()?.toLowerCase(); + const allowedExtensions = ["jpg", "jpeg", "png", "svg"]; + + if ( + !allowedTypes.includes(file.type) && + !allowedExtensions.includes(fileExtension || "") + ) { + toast.error("Only JPG, JPEG, PNG, and SVG files are allowed"); + return; + } + + if (file.size > 2 * 1024 * 1024) { + toast.error("Image size must be less than 2MB"); + return; + } + + const isSvg = file.type === "image/svg+xml" || fileExtension === "svg"; + + if (isSvg) { + const text = await file.text(); + const sanitizedDataUrl = sanitizeSvg(text); + if (!sanitizedDataUrl) { + toast.error("Invalid SVG file"); + return; + } + try { + await updateApplication({ + applicationId, + icon: sanitizedDataUrl, + }); + toast.success("Icon saved!"); + await utils.application.one.invalidate({ applicationId }); + setOpen(false); + } catch (_error) { + toast.error("Error saving icon"); + } + return; + } + + const reader = new FileReader(); + reader.onload = async (event) => { + const result = event.target?.result as string; + try { + await updateApplication({ + applicationId, + icon: result, + }); + toast.success("Icon saved!"); + await utils.application.one.invalidate({ applicationId }); + setOpen(false); + } catch (_error) { + toast.error("Error saving icon"); + } + }; + reader.readAsDataURL(file); + }; + + return ( + + + + + + + + Change Icon + {icon && ( + + )} + + + +
+
+ + setIconSearchQuery(e.target.value)} + className="pl-9" + /> +
+ +
+ {displayedIcons.length === 0 ? ( +
+ No icons found +
+ ) : ( + <> +
+ {displayedIcons.map((i) => ( + + ))} +
+ {hasMoreIcons && ( +
+ +
+ )} + + )} +
+ +
+

+ or upload a custom icon +

+ +
+ Supported formats: JPG, JPEG, PNG, SVG (max 2MB) +
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/logs/show.tsx b/apps/dokploy/components/dashboard/application/logs/show.tsx index cbb6bce09..06b257766 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 1854bd3e5..053e644b7 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 d0df60098..f3d60f27f 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/delete-service.tsx b/apps/dokploy/components/dashboard/compose/delete-service.tsx index 9d417ee91..35fe01ff9 100644 --- a/apps/dokploy/components/dashboard/compose/delete-service.tsx +++ b/apps/dokploy/components/dashboard/compose/delete-service.tsx @@ -46,6 +46,8 @@ interface Props { } export const DeleteService = ({ id, type }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canDelete = permissions?.service.delete ?? false; const [isOpen, setIsOpen] = useState(false); const queryMap = { @@ -55,6 +57,7 @@ export const DeleteService = ({ id, type }: Props) => { mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), mariadb: () => api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), + libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), application: () => api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), @@ -70,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(), @@ -96,6 +100,7 @@ export const DeleteService = ({ id, type }: Props) => { redisId: id || "", mysqlId: id || "", mariadbId: id || "", + libsqlId: id || "", applicationId: id || "", composeId: id || "", deleteVolumes, @@ -123,6 +128,8 @@ export const DeleteService = ({ id, type }: Props) => { data?.applicationStatus === "running") || (data && "composeStatus" in data && data?.composeStatus === "running"); + if (!canDelete) return null; + return ( diff --git a/apps/dokploy/components/dashboard/compose/general/actions.tsx b/apps/dokploy/components/dashboard/compose/general/actions.tsx index 8067a7db6..d04725e26 100644 --- a/apps/dokploy/components/dashboard/compose/general/actions.tsx +++ b/apps/dokploy/components/dashboard/compose/general/actions.tsx @@ -19,6 +19,9 @@ interface Props { } export const ComposeActions = ({ composeId }: Props) => { const router = useRouter(); + const { data: permissions } = api.user.getPermissions.useQuery(); + const canDeploy = permissions?.deployment.create ?? false; + const canUpdateService = permissions?.service.create ?? false; const { data, refetch } = api.compose.one.useQuery( { composeId, @@ -35,162 +38,169 @@ export const ComposeActions = ({ composeId }: Props) => { return (
- { - await deploy({ - composeId: composeId, - }) - .then(() => { - toast.success("Compose deployed successfully"); - refetch(); - router.push( - `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`, - ); - }) - .catch(() => { - toast.error("Error deploying compose"); - }); - }} - > - - - { - await redeploy({ - composeId: composeId, - }) - .then(() => { - toast.success("Compose reloaded successfully"); - refetch(); - }) - .catch(() => { - toast.error("Error reloading compose"); - }); - }} - > - - - {data?.composeType === "docker-compose" && - data?.composeStatus === "idle" ? ( + {canDeploy && ( { - await start({ + await deploy({ composeId: composeId, }) .then(() => { - toast.success("Compose started successfully"); + toast.success("Compose deployed successfully"); refetch(); + router.push( + `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`, + ); }) .catch(() => { - toast.error("Error starting compose"); + toast.error("Error deploying compose"); }); }} > - ) : ( + )} + {canDeploy && ( { - await stop({ + await redeploy({ composeId: composeId, }) .then(() => { - toast.success("Compose stopped successfully"); + toast.success("Compose reloaded successfully"); refetch(); }) .catch(() => { - toast.error("Error stopping compose"); + toast.error("Error reloading compose"); }); }} > )} + {canDeploy && + (data?.composeType === "docker-compose" && + data?.composeStatus === "idle" ? ( + { + await start({ + composeId: composeId, + }) + .then(() => { + toast.success("Compose started successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error starting compose"); + }); + }} + > + + + ) : ( + { + await stop({ + composeId: composeId, + }) + .then(() => { + toast.success("Compose stopped successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error stopping compose"); + }); + }} + > + + + ))} { Open Terminal -
- Autodeploy - { - await update({ - composeId, - autoDeploy: enabled, - }) - .then(async () => { - toast.success("Auto Deploy Updated"); - await refetch(); + {canUpdateService && ( +
+ Autodeploy + { + await update({ + composeId, + autoDeploy: enabled, }) - .catch(() => { - toast.error("Error updating Auto Deploy"); - }); - }} - className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" - /> -
+ .then(async () => { + toast.success("Auto Deploy Updated"); + await refetch(); + }) + .catch(() => { + toast.error("Error updating Auto Deploy"); + }); + }} + className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary" + /> +
+ )}
); }; diff --git a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx index 8193ec8b6..e9d024fd3 100644 --- a/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx +++ b/apps/dokploy/components/dashboard/compose/general/compose-file-editor.tsx @@ -26,6 +26,8 @@ const AddComposeFile = z.object({ type AddComposeFile = z.infer; export const ComposeFileEditor = ({ composeId }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canUpdate = permissions?.service.create ?? false; const utils = api.useUtils(); const { data, refetch } = api.compose.one.useQuery( { @@ -93,7 +95,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => { // Add keyboard shortcut for Ctrl+S/Cmd+S useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) { + if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) { e.preventDefault(); form.handleSubmit(onSubmit)(); } @@ -164,14 +166,16 @@ services:
- + {canUpdate && ( + + )}
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 4ad4f741c..c84a55bb3 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx @@ -1,5 +1,5 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { KeyRoundIcon, LockIcon, X } from "lucide-react"; +import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/router"; import { useEffect } from "react"; @@ -230,10 +230,8 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => { Watch Paths - -
- ? -
+ +

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 39f025438..7ea71fc89 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx @@ -1,5 +1,5 @@ import { 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 159ab3485..4c3067b15 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 3ef31c26f..26880e9b5 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 ba8e4caf5..7b212acb9 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 9aa118548..ebffaccb3 100644 --- a/apps/dokploy/components/dashboard/database/backups/show-backups.tsx +++ b/apps/dokploy/components/dashboard/database/backups/show-backups.tsx @@ -53,14 +53,16 @@ export const ShowBackups = ({ const queryMap = backupType === "database" ? { - postgres: () => - api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), - mysql: () => - api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), mariadb: () => api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + mysql: () => + api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), + postgres: () => + api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), + libsql: () => + api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), "web-server": () => api.user.getBackups.useQuery(), } : { @@ -77,10 +79,11 @@ export const ShowBackups = ({ const mutationMap = backupType === "database" ? { - postgres: api.backup.manualBackupPostgres.useMutation(), - mysql: api.backup.manualBackupMySql.useMutation(), mariadb: api.backup.manualBackupMariadb.useMutation(), mongo: api.backup.manualBackupMongo.useMutation(), + mysql: api.backup.manualBackupMySql.useMutation(), + postgres: api.backup.manualBackupPostgres.useMutation(), + libsql: api.backup.manualBackupLibsql.useMutation(), "web-server": api.backup.manualBackupWebServer.useMutation(), } : { diff --git a/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx b/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx index e46b33a6a..22b132f16 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/terminal-line.tsx b/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx index 9d4f47c4a..bed5c6f5d 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 80a79eb2b..01c68e49a 100644 --- a/apps/dokploy/components/dashboard/docker/logs/utils.ts +++ b/apps/dokploy/components/dashboard/docker/logs/utils.ts @@ -74,6 +74,18 @@ export function parseLogs(logString: string): LogLine[] { // Detect log type based on message content export const getLogType = (message: string): LogStyle => { + // Detect HTTP statusCode + const statusMatch = message.match(/"statusCode"\s*:\s*"?(\d{3})"?/); + + if (statusMatch) { + const statusCode = Number(statusMatch[1]); + + if (statusCode >= 500) return LOG_STYLES.error; + if (statusCode >= 400) return LOG_STYLES.warning; + if (statusCode >= 200 && statusCode < 300) return LOG_STYLES.success; + return LOG_STYLES.info; + } + const lowerMessage = message.toLowerCase(); if ( diff --git a/apps/dokploy/components/dashboard/docker/remove/remove-container.tsx b/apps/dokploy/components/dashboard/docker/remove/remove-container.tsx new file mode 100644 index 000000000..3b6cd9875 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/remove/remove-container.tsx @@ -0,0 +1,66 @@ +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { api } from "@/utils/api"; + +interface Props { + containerId: string; + serverId?: string; +} + +export const RemoveContainerDialog = ({ containerId, serverId }: Props) => { + const utils = api.useUtils(); + const { mutateAsync, isPending } = api.docker.removeContainer.useMutation(); + + return ( + + + e.preventDefault()} + > + Remove Container + + + + + Are you sure? + + This will permanently remove the container{" "} + {containerId}. If the + container is running, it will be forcefully stopped and removed. + This action cannot be undone. + + + + Cancel + { + await mutateAsync({ containerId, serverId }) + .then(async () => { + toast.success("Container removed successfully"); + await utils.docker.getContainers.invalidate(); + }) + .catch((err) => { + toast.error(err.message); + }); + }} + > + Confirm + + + + + ); +}; diff --git a/apps/dokploy/components/dashboard/docker/show/colums.tsx b/apps/dokploy/components/dashboard/docker/show/columns.tsx similarity index 88% rename from apps/dokploy/components/dashboard/docker/show/colums.tsx rename to apps/dokploy/components/dashboard/docker/show/columns.tsx index 74fe6819e..33c104d97 100644 --- a/apps/dokploy/components/dashboard/docker/show/colums.tsx +++ b/apps/dokploy/components/dashboard/docker/show/columns.tsx @@ -10,7 +10,9 @@ import { } from "@/components/ui/dropdown-menu"; import { ShowContainerConfig } from "../config/show-container-config"; import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs"; +import { RemoveContainerDialog } from "../remove/remove-container"; import { DockerTerminalModal } from "../terminal/docker-terminal-modal"; +import { UploadFileModal } from "../upload/upload-file-modal"; import type { Container } from "./show-containers"; export const columns: ColumnDef[] = [ @@ -127,6 +129,16 @@ export const columns: ColumnDef[] = [ > Terminal + + Upload File + + ); diff --git a/apps/dokploy/components/dashboard/docker/show/show-containers.tsx b/apps/dokploy/components/dashboard/docker/show/show-containers.tsx index 69b0a0da2..8a19566e8 100644 --- a/apps/dokploy/components/dashboard/docker/show/show-containers.tsx +++ b/apps/dokploy/components/dashboard/docker/show/show-containers.tsx @@ -35,7 +35,7 @@ import { TableRow, } from "@/components/ui/table"; import { api, type RouterOutputs } from "@/utils/api"; -import { columns } from "./colums"; +import { columns } from "./columns"; export type Container = NonNullable< RouterOutputs["docker"]["getContainers"] >[0]; diff --git a/apps/dokploy/components/dashboard/docker/upload/upload-file-modal.tsx b/apps/dokploy/components/dashboard/docker/upload/upload-file-modal.tsx new file mode 100644 index 000000000..8838ac094 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/upload/upload-file-modal.tsx @@ -0,0 +1,187 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { Upload } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { Dropzone } from "@/components/ui/dropzone"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { api } from "@/utils/api"; +import { + uploadFileToContainerSchema, + type UploadFileToContainer, +} from "@/utils/schema"; + +interface Props { + containerId: string; + serverId?: string; + children?: React.ReactNode; +} + +export const UploadFileModal = ({ children, containerId, serverId }: Props) => { + const [open, setOpen] = useState(false); + + const { mutateAsync: uploadFile, isPending: isLoading } = + api.docker.uploadFileToContainer.useMutation({ + onSuccess: () => { + toast.success("File uploaded successfully"); + setOpen(false); + form.reset(); + }, + onError: (error) => { + toast.error(error.message || "Failed to upload file to container"); + }, + }); + + const form = useForm({ + resolver: zodResolver(uploadFileToContainerSchema), + defaultValues: { + containerId, + destinationPath: "/", + serverId: serverId || undefined, + }, + }); + + const file = form.watch("file"); + + const onSubmit = async (values: UploadFileToContainer) => { + if (!values.file) { + toast.error("Please select a file to upload"); + return; + } + + const formData = new FormData(); + formData.append("containerId", values.containerId); + formData.append("file", values.file); + formData.append("destinationPath", values.destinationPath); + if (values.serverId) { + formData.append("serverId", values.serverId); + } + + await uploadFile(formData); + }; + + return ( + + + e.preventDefault()} + > + {children} + + + + + + + Upload File to Container + + + Upload a file directly into the container's filesystem + + + +
+ + ( + + Destination Path + + + + +

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

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