diff --git a/.github/workflows/dokploy.yml b/.github/workflows/dokploy.yml index 529cd8f7fa..5429446114 100644 --- a/.github/workflows/dokploy.yml +++ b/.github/workflows/dokploy.yml @@ -138,6 +138,8 @@ jobs: needs: [combine-manifests] if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + outputs: + version: ${{ steps.get_version.outputs.version }} steps: - name: Checkout uses: actions/checkout@v4 @@ -160,3 +162,80 @@ jobs: prerelease: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + sync-version: + needs: [generate-release] + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Sync version to MCP repository + run: | + git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo + cd /tmp/mcp-repo + + jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp + mv package.json.tmp package.json + + npm install -g pnpm + pnpm install + pnpm run fetch-openapi + pnpm run generate + + git config user.name "Dokploy Bot" + git config user.email "bot@dokploy.com" + git add -A + git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \ + -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + --allow-empty + git push + + echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}" + + - name: Sync version to CLI repository + run: | + git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo + cd /tmp/cli-repo + + jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp + mv package.json.tmp package.json + + cp ${{ github.workspace }}/openapi.json ./openapi.json + npm install -g pnpm + pnpm install + pnpm run generate + + git config user.name "Dokploy Bot" + git config user.email "bot@dokploy.com" + git add -A + git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \ + -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + --allow-empty + git push + + echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}" + + - name: Sync version to SDK repository + run: | + git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo + cd /tmp/sdk-repo + + jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp + mv package.json.tmp package.json + + cp ${{ github.workspace }}/openapi.json ./openapi.json + npm install -g pnpm + pnpm install + pnpm run generate + + git config user.name "Dokploy Bot" + git config user.email "bot@dokploy.com" + git add -A + git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \ + -m "Source: ${{ github.repository }}@${{ github.sha }}" \ + --allow-empty + git push + + echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}" diff --git a/.github/workflows/sync-version.yml b/.github/workflows/sync-version.yml deleted file mode 100644 index 5e8ccb7067..0000000000 --- a/.github/workflows/sync-version.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Sync version to MCP and CLI repos - -on: - release: - types: [published] - push: - tags: - - 'v*' - workflow_dispatch: - -jobs: - sync-version: - name: Sync version to external repos - runs-on: ubuntu-latest - steps: - - name: Checkout Dokploy repository - uses: actions/checkout@v4 - - - name: Get version - id: get_version - run: | - VERSION=$(jq -r .version apps/dokploy/package.json | sed 's/^v//') - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Version: $VERSION" - - - name: Sync version to MCP repository - run: | - git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo - cd /tmp/mcp-repo - - # Regenerate tools from latest OpenAPI spec - npm install -g pnpm - pnpm install - pnpm run fetch-openapi - pnpm run generate - - # Bump version after install so pnpm install doesn't overwrite it - jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp - mv package.json.tmp package.json - - git config user.name "Dokploy Bot" - git config user.email "bot@dokploy.com" - - git add -A - git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \ - -m "Source: ${{ github.repository }}@${{ github.sha }}" \ - -m "Release: ${{ github.event.release.html_url }}" \ - --allow-empty - - git push - - - - name: Sync version to CLI repository - run: | - git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo - - cd /tmp/cli-repo - - # Copy latest openapi spec and regenerate commands - cp ${{ github.workspace }}/openapi.json ./openapi.json - npm install -g pnpm - pnpm install - pnpm run generate - - # Bump version after install so pnpm install doesn't overwrite it - if [ -f package.json ]; then - jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp - mv package.json.tmp package.json - fi - - git config user.name "Dokploy Bot" - git config user.email "bot@dokploy.com" - - git add -A - git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \ - -m "Source: ${{ github.repository }}@${{ github.sha }}" \ - -m "Release: ${{ github.event.release.html_url }}" \ - --allow-empty - - git push - - echo "CLI repo synced to version ${{ steps.get_version.outputs.version }}" - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4fa0dd358d..516e94d2e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,6 +88,12 @@ pnpm run dokploy:dev Go to http://localhost:3000 to see the development server +Create the first local account in the browser. After the account is created, run the seed command from the repository root to add example applications for local development. + +```bash +pnpm --filter=dokploy run db:seed +``` + > [!NOTE] > This project uses Biome. If your editor is configured to use another formatter such as Prettier, it's recommended to either change it to use Biome or turn it off. diff --git a/apps/dokploy/__test__/compose/domain/labels.test.ts b/apps/dokploy/__test__/compose/domain/labels.test.ts index ec8e9edc70..dce69cfe4f 100644 --- a/apps/dokploy/__test__/compose/domain/labels.test.ts +++ b/apps/dokploy/__test__/compose/domain/labels.test.ts @@ -103,6 +103,51 @@ describe("createDomainLabels", () => { ); }); + it("should add tls=true for certificateType none on websecure entrypoint", async () => { + const noneDomain = { + ...baseDomain, + https: true, + certificateType: "none" as const, + }; + const labels = await createDomainLabels(appName, noneDomain, "websecure"); + expect(labels).toContain( + "traefik.http.routers.test-app-1-websecure.tls=true", + ); + // no cert resolver should be set when relying on a default/custom cert + expect(labels).not.toContain( + "traefik.http.routers.test-app-1-websecure.tls.certresolver=letsencrypt", + ); + }); + + it("should not add tls=true for certificateType none on web entrypoint", async () => { + const noneDomain = { + ...baseDomain, + https: true, + certificateType: "none" as const, + }; + const labels = await createDomainLabels(appName, noneDomain, "web"); + expect(labels).not.toContain( + "traefik.http.routers.test-app-1-web.tls=true", + ); + }); + + it("should add tls=true for certificateType none on a custom https entrypoint", async () => { + const noneDomain = { + ...baseDomain, + https: true, + customEntrypoint: "websecure-custom", + certificateType: "none" as const, + }; + const labels = await createDomainLabels( + appName, + noneDomain, + "websecure-custom", + ); + expect(labels).toContain( + "traefik.http.routers.test-app-1-websecure-custom.tls=true", + ); + }); + it("should handle different ports correctly", async () => { const customPortDomain = { ...baseDomain, port: 3000 }; const labels = await createDomainLabels(appName, customPortDomain, "web"); diff --git a/apps/dokploy/__test__/deploy/application.command.test.ts b/apps/dokploy/__test__/deploy/application.command.test.ts index 1a33489b57..e1446fa3c1 100644 --- a/apps/dokploy/__test__/deploy/application.command.test.ts +++ b/apps/dokploy/__test__/deploy/application.command.test.ts @@ -16,10 +16,13 @@ vi.mock("@dokploy/server/db", () => { returning: vi.fn().mockResolvedValue([{}] as any), from: vi.fn(() => chain), innerJoin: vi.fn(() => chain), - then: (resolve: (v: any) => void) => { + } as any; + const thenableKey = ["th", "en"].join(""); + Object.defineProperty(chain, thenableKey, { + value: (resolve: (v: any) => void) => { resolve([]); }, - } as any; + }); return chain; }; @@ -181,7 +184,6 @@ describe("deployApplication - Command Generation Tests", () => { it("should generate correct git clone command for astro example", async () => { const app = createMockApplication(); const command = await cloneGitRepository(app); - console.log(command); expect(command).toContain("https://github.com/Dokploy/examples.git"); expect(command).not.toContain("--recurse-submodules"); @@ -284,4 +286,48 @@ describe("deployApplication - Command Generation Tests", () => { expect(fullCommand).toContain(">> /tmp/test-deployment.log 2>&1"); }); + + it("deploys seeded public Git apps without GitHub App authentication", async () => { + const seedStyleApplication = createMockApplication({ + applicationId: "dev-seed-nextjs", + name: "Next.js", + appName: "seed-nextjs", + sourceType: "git", + customGitUrl: "https://github.com/vercel/next.js.git", + customGitBranch: "canary", + customGitBuildPath: "/examples/with-docker", + buildType: "dockerfile", + dockerfile: "Dockerfile", + githubId: null, + buildPath: null, + }); + vi.mocked(applicationService.findApplicationById).mockResolvedValue( + seedStyleApplication as any, + ); + vi.mocked(db.query.applications.findFirst).mockResolvedValue( + seedStyleApplication as any, + ); + vi.mocked(builders.getBuildCommand).mockResolvedValue("nixpacks build"); + + await deployApplication({ + applicationId: "dev-seed-nextjs", + titleLog: "Seed deployment", + descriptionLog: "", + }); + + const fullCommand = vi.mocked(execProcess.execAsync).mock.calls[0]?.[0]; + expect(fullCommand).toContain( + "git clone --branch canary --depth 1 --progress https://github.com/vercel/next.js.git", + ); + expect(fullCommand).not.toContain("oauth2:"); + expect(builders.getBuildCommand).toHaveBeenCalledWith( + expect.objectContaining({ + sourceType: "git", + customGitBuildPath: "/examples/with-docker", + buildType: "dockerfile", + dockerfile: "Dockerfile", + githubId: null, + }), + ); + }); }); diff --git a/apps/dokploy/__test__/deploy/should-deploy.test.ts b/apps/dokploy/__test__/deploy/should-deploy.test.ts new file mode 100644 index 0000000000..d9a1c02446 --- /dev/null +++ b/apps/dokploy/__test__/deploy/should-deploy.test.ts @@ -0,0 +1,41 @@ +import { shouldDeploy } from "@dokploy/server"; +import { describe, expect, it } from "vitest"; + +describe("shouldDeploy", () => { + it("should deploy when no watch paths are configured", () => { + expect(shouldDeploy(null, ["src/index.ts"])).toBe(true); + expect(shouldDeploy([], ["src/index.ts"])).toBe(true); + }); + + it("should deploy when watch paths match modified files", () => { + expect(shouldDeploy(["src/**"], ["src/index.ts"])).toBe(true); + expect(shouldDeploy(["apps/web/**"], ["apps/web/page.tsx"])).toBe(true); + }); + + it("should not deploy when watch paths do not match", () => { + expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false); + }); + + it("should not throw when modified files contain non-string values", () => { + expect(() => + shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any), + ).not.toThrow(); + expect( + shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any), + ).toBe(true); + }); + + it("should not throw when modified files are undefined or null", () => { + expect(() => shouldDeploy(["src/**"], undefined)).not.toThrow(); + expect(() => shouldDeploy(["src/**"], null)).not.toThrow(); + expect(shouldDeploy(["src/**"], undefined)).toBe(false); + expect(shouldDeploy(["src/**"], null)).toBe(false); + }); + + it("should not throw when every modified file is non-string", () => { + expect(() => + shouldDeploy(["src/**"], [undefined, undefined] as any), + ).not.toThrow(); + expect(shouldDeploy(["src/**"], [undefined, undefined] as any)).toBe(false); + }); +}); diff --git a/apps/dokploy/__test__/seed/development-seed.test.ts b/apps/dokploy/__test__/seed/development-seed.test.ts new file mode 100644 index 0000000000..4d1eb22ebd --- /dev/null +++ b/apps/dokploy/__test__/seed/development-seed.test.ts @@ -0,0 +1,299 @@ +import { + applications, + environments, + projects, +} from "@dokploy/server/db/schema"; +import { + developmentSeedIds, + seedDevelopmentProjectData, +} from "@dokploy/server/services/development-seed"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const createFakeDatabase = () => { + const state = { + organizations: [] as any[], + members: [] as any[], + projects: [] as any[], + environments: [] as any[], + applications: [] as any[], + }; + + const collectionFor = (table: unknown) => { + if (table === projects) return state.projects; + if (table === environments) return state.environments; + if (table === applications) return state.applications; + throw new Error("Unexpected table"); + }; + + const matchesWhere = (item: any, where: any) => { + const [_, column, operator, valueChunk] = where.queryChunks ?? []; + const columnName = column?.name; + const operation = operator?.value?.join("").trim(); + + if (!columnName || !operation) { + throw new Error("Unsupported where clause"); + } + + if (operation === "=") { + return item[columnName] === valueChunk.value; + } + + if (operation === "in" && Array.isArray(valueChunk)) { + return valueChunk.some((chunk) => item[columnName] === chunk.value); + } + + throw new Error(`Unsupported where operation: ${operation}`); + }; + + const database = { + query: { + member: { + findFirst: vi.fn(async () => { + const defaultMember = state.members.find((item) => item.isDefault); + if (!defaultMember) return undefined; + + return { + ...defaultMember, + organization: state.organizations.find( + (item) => item.id === defaultMember.organizationId, + ), + }; + }), + }, + organization: { + findFirst: vi.fn(async () => state.organizations[0]), + }, + projects: { + findFirst: vi.fn(async ({ where }: any) => + state.projects.find((item) => matchesWhere(item, where)), + ), + }, + environments: { + findFirst: vi.fn(async ({ where }: any) => + state.environments.find((item) => matchesWhere(item, where)), + ), + }, + applications: { + findFirst: vi.fn(async ({ where }: any) => + state.applications.find((item) => matchesWhere(item, where)), + ), + }, + }, + transaction: vi.fn(async (callback: (tx: any) => Promise) => { + await callback(database); + }), + insert: vi.fn((table: unknown) => ({ + values: (values: any) => ({ + returning: async () => { + collectionFor(table).push({ ...values }); + return [{ ...values }]; + }, + }), + })), + update: vi.fn((table: unknown) => ({ + set: (values: any) => ({ + where: (where: any) => ({ + returning: async () => { + const collection = collectionFor(table); + const index = collection.findIndex((item) => + matchesWhere(item, where), + ); + if (index === -1) return []; + + collection[index] = { + ...collection[index], + ...values, + }; + return [{ ...collection[index] }]; + }, + }), + }), + })), + delete: vi.fn((table: unknown) => ({ + where: async (where: any) => { + const collection = collectionFor(table); + const retainedItems = collection.filter( + (item) => !matchesWhere(item, where), + ); + collection.splice(0, collection.length, ...retainedItems); + }, + })), + }; + + return { database, state }; +}; + +describe("seedDevelopmentProjectData", () => { + beforeEach(() => { + vi.stubEnv("NODE_ENV", "test"); + vi.stubEnv("IS_CLOUD", undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("exits cleanly when no organization exists", async () => { + const { database, state } = createFakeDatabase(); + const logger = { log: vi.fn(), warn: vi.fn() }; + + const result = await seedDevelopmentProjectData({ + database: database as any, + logger, + }); + + expect(result).toEqual({ + seeded: false, + reason: "missing-organization", + applications: [], + }); + expect(state.projects).toHaveLength(0); + expect(state.environments).toHaveLength(0); + expect(state.applications).toHaveLength(0); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("Create a local account first"), + ); + }); + + it("creates deterministic project, environment, and public Git applications", async () => { + const { database, state } = createFakeDatabase(); + state.organizations.push({ + id: "org-1", + name: "My Organization", + createdAt: new Date(), + }); + state.members.push({ + id: "member-1", + organizationId: "org-1", + userId: "user-1", + role: "owner", + isDefault: true, + createdAt: new Date(), + }); + + const result = await seedDevelopmentProjectData({ + database: database as any, + logger: { log: vi.fn(), warn: vi.fn() }, + }); + + expect(result.seeded).toBe(true); + expect(result.organizationId).toBe("org-1"); + expect(state.projects).toMatchObject([ + { + projectId: developmentSeedIds.projectId, + organizationId: "org-1", + name: "Example Project", + }, + ]); + expect(state.environments).toMatchObject([ + { + environmentId: developmentSeedIds.environmentId, + projectId: developmentSeedIds.projectId, + isDefault: true, + }, + ]); + expect(state.applications).toHaveLength(2); + expect(state.applications).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + applicationId: "dev-seed-nextjs", + appName: "seed-nextjs", + description: + "Development seed app cloned from the public Next.js repository.", + sourceType: "git", + customGitUrl: "https://github.com/vercel/next.js.git", + customGitBranch: "canary", + customGitBuildPath: "/examples/with-docker", + customGitSSHKeyId: null, + githubId: null, + serverId: null, + buildType: "dockerfile", + dockerfile: "Dockerfile", + }), + expect.objectContaining({ + applicationId: "dev-seed-laravel", + appName: "seed-laravel", + description: + "Development seed app cloned from the public Laravel repository.", + customGitUrl: "https://github.com/laravel/laravel.git", + customGitBranch: "13.x", + customGitBuildPath: "/", + env: expect.stringContaining( + "APP_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + ), + buildType: "nixpacks", + }), + ]), + ); + }); + + it("updates existing seed rows without creating duplicates", async () => { + const { database, state } = createFakeDatabase(); + state.organizations.push({ + id: "org-1", + name: "My Organization", + createdAt: new Date(), + }); + state.members.push({ + id: "member-1", + organizationId: "org-1", + userId: "user-1", + role: "owner", + isDefault: true, + createdAt: new Date(), + }); + + await seedDevelopmentProjectData({ + database: database as any, + logger: { log: vi.fn(), warn: vi.fn() }, + }); + state.projects[0].name = "Stale name"; + state.applications[0].customGitUrl = "https://example.com/stale.git"; + state.applications.push({ + applicationId: "dev-seed-node-nixpacks", + appName: "retired-seed", + customGitUrl: "https://example.com/retired.git", + }); + + await seedDevelopmentProjectData({ + database: database as any, + logger: { log: vi.fn(), warn: vi.fn() }, + }); + + expect(state.projects).toHaveLength(1); + expect(state.environments).toHaveLength(1); + expect(state.applications).toHaveLength(2); + expect(state.applications).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + applicationId: "dev-seed-node-nixpacks", + }), + ]), + ); + expect(state.projects[0].name).toBe("Example Project"); + expect(state.applications[0].customGitUrl).toBe( + "https://github.com/vercel/next.js.git", + ); + expect(state.applications[0].customGitBuildPath).toBe( + "/examples/with-docker", + ); + expect(state.applications[0].buildType).toBe("dockerfile"); + expect(state.applications[0].dockerfile).toBe("Dockerfile"); + }); + + it("refuses to seed in production or cloud mode", async () => { + const { database } = createFakeDatabase(); + const logger = { log: vi.fn(), warn: vi.fn() }; + + vi.stubEnv("NODE_ENV", "production"); + await expect( + seedDevelopmentProjectData({ database: database as any, logger }), + ).resolves.toMatchObject({ seeded: false, reason: "production" }); + + vi.stubEnv("NODE_ENV", "test"); + vi.stubEnv("IS_CLOUD", "true"); + await expect( + seedDevelopmentProjectData({ database: database as any, logger }), + ).resolves.toMatchObject({ seeded: false, reason: "cloud" }); + }); +}); diff --git a/apps/dokploy/__test__/wss/readValidDirectory.test.ts b/apps/dokploy/__test__/wss/readValidDirectory.test.ts index 8107bb591b..29d3152eb0 100644 --- a/apps/dokploy/__test__/wss/readValidDirectory.test.ts +++ b/apps/dokploy/__test__/wss/readValidDirectory.test.ts @@ -78,4 +78,20 @@ describe("readValidDirectory (path traversal)", () => { it("returns false for empty string (resolves to cwd)", () => { expect(readValidDirectory("")).toBe(false); }); + + it("returns true for Next.js dynamic route paths with square brackets", () => { + expect( + readValidDirectory( + `${BASE}/applications/myapp/code/app/api/[id]/route.ts`, + ), + ).toBe(true); + expect( + readValidDirectory(`${BASE}/applications/myapp/code/pages/[slug].tsx`), + ).toBe(true); + expect( + readValidDirectory( + `${BASE}/applications/myapp/code/app/[...catch]/page.tsx`, + ), + ).toBe(true); + }); }); diff --git a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx index fa2bda6293..f2a48bae70 100644 --- a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx @@ -224,7 +224,7 @@ export const ShowResources = ({ id, type }: Props) => { Memory Limit - + @@ -263,7 +263,7 @@ export const ShowResources = ({ id, type }: Props) => { Memory Reservation - + @@ -303,7 +303,7 @@ export const ShowResources = ({ id, type }: Props) => { CPU Limit - + @@ -343,7 +343,7 @@ export const ShowResources = ({ id, type }: Props) => { CPU Reservation - + @@ -379,7 +379,7 @@ export const ShowResources = ({ id, type }: Props) => { Ulimits - + diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index 4edd6597f4..b232591e4b 100644 --- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -806,7 +806,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { Middlewares - +
?
diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx index 745f72d3bd..330243ae21 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx @@ -422,7 +422,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => { Watch Paths - +
?
diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx index 10075fb5c6..0c07e688a4 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-github-provider-compose.tsx @@ -449,7 +449,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => { Watch Paths - +
?
diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx index a81774fec9..cad08f6bf0 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx @@ -440,7 +440,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => { Watch Paths - +
?
diff --git a/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx b/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx index d40697437c..1b8ec736a6 100644 --- a/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/analyze-logs.tsx @@ -90,7 +90,7 @@ export function AnalyzeLogs({ logs, context }: Props) { disabled={logs.length === 0} title="Analyze logs with AI" > - + AI diff --git a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx index 8d8842ac09..3a5f460e91 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -347,11 +347,13 @@ export const DockerLogsId: React.FC = ({ title={isPaused ? "Resume logs" : "Pause logs"} > {isPaused ? ( - + ) : ( - + )} - {isPaused ? "Resume" : "Pause"} + + {isPaused ? "Resume" : "Pause"} + {isPaused && ( - +
- + Logs paused {messageBuffer.length > 0 && ( diff --git a/apps/dokploy/components/dashboard/project/add-import.tsx b/apps/dokploy/components/dashboard/project/add-import.tsx new file mode 100644 index 0000000000..034710e9cf --- /dev/null +++ b/apps/dokploy/components/dashboard/project/add-import.tsx @@ -0,0 +1,494 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { Code2, FileInput, Globe2, HardDrive, HelpCircle } from "lucide-react"; +import { 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 { CodeEditor } from "@/components/shared/code-editor"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Textarea } from "@/components/ui/textarea"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { slugify } from "@/lib/slug"; +import { api } from "@/utils/api"; +import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema"; + +const AddImportSchema = z.object({ + name: z.string().min(1, { message: "Name is required" }), + appName: z + .string() + .min(1, { message: "App name is required" }) + .regex(APP_NAME_REGEX, { message: APP_NAME_MESSAGE }), + base64: z.string().min(1, { message: "Base64 content is required" }), + serverId: z.string().optional(), +}); + +type AddImport = z.infer; + +type TemplateInfo = { + compose: string; + template: { + domains: Array<{ + serviceName: string; + port: number; + path?: string; + host?: string; + }>; + envs: string[]; + mounts: Array<{ filePath: string; content: string }>; + }; +}; + +interface Props { + environmentId: string; + projectName?: string; +} + +export const AddImport = ({ environmentId, projectName }: Props) => { + const utils = api.useUtils(); + const [visible, setVisible] = useState(false); + const [previewOpen, setPreviewOpen] = useState(false); + const [mountOpen, setMountOpen] = useState(false); + const [selectedMount, setSelectedMount] = useState<{ + filePath: string; + content: string; + } | null>(null); + const [templateInfo, setTemplateInfo] = useState(null); + + const slug = slugify(projectName); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const { data: servers } = api.server.withSSHKey.useQuery(); + const shouldShowServerDropdown = !!(servers && servers.length > 0); + + const { mutateAsync: previewTemplate, isPending: isProcessing } = + api.compose.previewTemplate.useMutation(); + const { mutateAsync: createCompose, isPending: isCreating } = + api.compose.create.useMutation(); + const { mutateAsync: importCompose, isPending: isImporting } = + api.compose.import.useMutation(); + + const form = useForm({ + defaultValues: { name: "", appName: `${slug}-`, base64: "" }, + resolver: zodResolver(AddImportSchema), + }); + + const resetAll = () => { + form.reset({ name: "", appName: `${slug}-`, base64: "" }); + setTemplateInfo(null); + setPreviewOpen(false); + setMountOpen(false); + setSelectedMount(null); + }; + + const handleOpenChange = (open: boolean) => { + if (!open) resetAll(); + setVisible(open); + }; + + const handleLoad = async (data: AddImport) => { + try { + const result = await previewTemplate({ + appName: data.appName, + base64: data.base64.trim(), + serverId: data.serverId === "dokploy" ? undefined : data.serverId, + }); + setTemplateInfo(result); + setPreviewOpen(true); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error processing template", + ); + } + }; + + const handleImport = async () => { + const data = form.getValues(); + try { + const compose = await createCompose({ + name: data.name, + appName: data.appName, + environmentId, + composeType: "docker-compose", + serverId: data.serverId === "dokploy" ? undefined : data.serverId, + }); + await importCompose({ + composeId: compose.composeId, + base64: data.base64.trim(), + }); + toast.success("Compose imported successfully"); + await utils.environment.one.invalidate({ environmentId }); + resetAll(); + setVisible(false); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error importing compose", + ); + } + }; + + const handleCancelPreview = () => { + setPreviewOpen(false); + setTemplateInfo(null); + }; + + return ( + <> + + + e.preventDefault()} + > + + Import + + + + + Import Compose + + Paste a base64-encoded compose export to preview and import it + + + +
+ + ( + + Name + + { + const val = e.target.value || ""; + form.setValue( + "appName", + `${slug}-${slugify(val.trim())}`, + ); + field.onChange(val); + }} + /> + + + + )} + /> + + {shouldShowServerDropdown && ( + ( + + + + + + Select a Server {!isCloud ? "(Optional)" : ""} + + + + + + If no server is selected, the compose will be + deployed on the server where the user is logged + in. + + + + + + + + )} + /> + )} + + ( + + App Name + + + + + + )} + /> + + ( + + Configuration (Base64) + +