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/apps/dokploy/__test__/preview-deployment/template.test.ts b/apps/dokploy/__test__/preview-deployment/template.test.ts new file mode 100644 index 0000000000..85388b4ce5 --- /dev/null +++ b/apps/dokploy/__test__/preview-deployment/template.test.ts @@ -0,0 +1,80 @@ +import { interpolateSubdomainTemplate } from "../../../../packages/server/src/services/preview-deployment"; +import { expect, test } from "vitest"; + +const baseVars = { + appName: "my-app", + prNumber: "123", + branchName: "feature/login-page", + uniqueId: "abc123", +}; + +test("replaces ${prNumber} variable", () => { + const result = interpolateSubdomainTemplate( + "${prNumber}.previews.example.com", + baseVars, + ); + expect(result).toBe("123.previews.example.com"); +}); + +test("replaces ${branchName} with slugified value", () => { + const result = interpolateSubdomainTemplate( + "${branchName}.previews.example.com", + baseVars, + ); + expect(result).toBe("feature-login-page.previews.example.com"); +}); + +test("replaces ${appName} variable", () => { + const result = interpolateSubdomainTemplate( + "${appName}-${prNumber}.example.com", + baseVars, + ); + expect(result).toBe("my-app-123.example.com"); +}); + +test("replaces ${uniqueId} variable", () => { + const result = interpolateSubdomainTemplate( + "preview-${uniqueId}.example.com", + baseVars, + ); + expect(result).toBe("preview-abc123.example.com"); +}); + +test("replaces all variables in a complex template", () => { + const result = interpolateSubdomainTemplate( + "${appName}-pr${prNumber}-${branchName}.example.com", + baseVars, + ); + expect(result).toBe("my-app-pr123-feature-login-page.example.com"); +}); + +test("leaves template unchanged when no variables present", () => { + const result = interpolateSubdomainTemplate("*.traefik.me", baseVars); + expect(result).toBe("*.traefik.me"); +}); + +test("slugifies branch names with special characters", () => { + const result = interpolateSubdomainTemplate("${branchName}.example.com", { + ...baseVars, + branchName: "feat/SOME_THING@v2.0", + }); + expect(result).toBe("feat-some-thing-v2-0.example.com"); +}); + +test("truncates slugified branch to 63 chars for DNS compliance", () => { + const longBranch = "a".repeat(100); + const result = interpolateSubdomainTemplate("${branchName}.example.com", { + ...baseVars, + branchName: longBranch, + }); + const subdomain = result.split(".")[0]!; + expect(subdomain.length).toBeLessThanOrEqual(63); +}); + +test("handles multiple occurrences of the same variable", () => { + const result = interpolateSubdomainTemplate( + "${prNumber}-${prNumber}.example.com", + baseVars, + ); + expect(result).toBe("123-123.example.com"); +}); diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx index 13b9a16032..4efa1fee10 100644 --- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx +++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx @@ -1,6 +1,6 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { HelpCircle, Plus, Settings2, X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -102,7 +102,32 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { const previewHttps = form.watch("previewHttps"); const wildcardDomain = form.watch("wildcardDomain"); - const isTraefikMeDomain = wildcardDomain?.includes("sslip.io") || false; + const isSslipDomain = wildcardDomain?.includes("sslip.io") || false; + + const templatePreview = useMemo(() => { + const template = wildcardDomain || "*.sslip.io"; + const appName = data?.appName || "my-app"; + const exampleVars: Record = { + appName, + prNumber: "42", + branchName: "feature-login", + uniqueId: "a1b2c3", + }; + let result = template.replace( + /\$\{(appName|prNumber|branchName|uniqueId)\}/g, + (_match: string, key: string) => exampleVars[key] ?? "", + ); + const hasUniqueVar = + template.includes("${prNumber}") || + template.includes("${branchName}") || + template.includes("${uniqueId}"); + if (!hasUniqueVar) { + result = result.replace("*", `${appName}-a1b2c3`); + } else { + result = result.replace("*", appName); + } + return result; + }, [wildcardDomain, data?.appName]); useEffect(() => { setIsEnabled(data?.isPreviewDeploymentsActive || false); @@ -171,7 +196,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
- {isTraefikMeDomain && ( + {isSslipDomain && ( Note: sslip.io is a public HTTP service and does not support SSL/HTTPS. HTTPS and certificate options will @@ -189,11 +214,33 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { control={form.control} name="wildcardDomain" render={({ field }) => ( - - Wildcard Domain + + Preview Domain Template - + + + + Use *. for + auto-generated subdomains, or template variables for + custom patterns. If the template includes{" "} + {"${prNumber}"} or{" "} + {"${branchName}"}, + no random suffix is appended. + + + {"${appName}"} + {"${prNumber}"} + {"${branchName}"} + {"${uniqueId}"} + + + Example: {templatePreview} + + )} 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) + +