diff --git a/.github/workflows/sync-version.yml b/.github/workflows/sync-version.yml index be19a2bb1b..667410a6dc 100644 --- a/.github/workflows/sync-version.yml +++ b/.github/workflows/sync-version.yml @@ -45,7 +45,7 @@ jobs: --allow-empty git push - + - name: Sync version to CLI repository run: | @@ -77,4 +77,3 @@ jobs: git push echo "CLI repo synced to version ${{ steps.get_version.outputs.version }}" - diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000000..02c39b5e36 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 24.8.0 diff --git a/apps/dokploy/components/dashboard/application/general/show-scaling-and-rollouts.tsx b/apps/dokploy/components/dashboard/application/general/show-scaling-and-rollouts.tsx new file mode 100644 index 0000000000..024e636fc4 --- /dev/null +++ b/apps/dokploy/components/dashboard/application/general/show-scaling-and-rollouts.tsx @@ -0,0 +1,249 @@ +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + 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 { + applicationId: string; +} + +type DeploymentStrategy = "standard" | "zero-downtime"; + +type UpdateConfigSwarm = { + Parallelism?: number; + Delay?: number; + FailureAction?: string; + Monitor?: number; + MaxFailureRatio?: number; + Order?: string; +} | null; + +const getDeploymentStrategy = ( + updateConfigSwarm: UpdateConfigSwarm | undefined, +): DeploymentStrategy => + updateConfigSwarm?.Order === "stop-first" ? "standard" : "zero-downtime"; + +const getEffectiveInstances = ( + replicas?: number, + modeSwarm?: { + Replicated?: { Replicas?: number }; + } | null, +) => modeSwarm?.Replicated?.Replicas ?? replicas ?? 1; + +const buildUpdateConfigSwarm = ( + currentUpdateConfigSwarm: UpdateConfigSwarm | undefined, + strategy: DeploymentStrategy, +) => { + const baseConfig = currentUpdateConfigSwarm ?? { + FailureAction: "rollback", + Parallelism: 1, + }; + + return { + ...baseConfig, + Parallelism: currentUpdateConfigSwarm?.Parallelism ?? 1, + Order: strategy === "standard" ? "stop-first" : "start-first", + }; +}; + +export const ShowScalingAndRollouts = ({ applicationId }: Props) => { + const { data: permissions } = api.user.getPermissions.useQuery(); + const canUpdateService = permissions?.service.create ?? false; + const { data, refetch } = api.application.one.useQuery( + { + applicationId, + }, + { enabled: !!applicationId }, + ); + const { mutateAsync: update } = api.application.update.useMutation(); + const [instances, setInstances] = useState(1); + const [strategy, setStrategy] = useState("zero-downtime"); + const [isSaving, setIsSaving] = useState(false); + + const effectiveInstances = getEffectiveInstances( + data?.replicas, + data?.modeSwarm, + ); + const currentStrategy = getDeploymentStrategy(data?.updateConfigSwarm); + const hasHealthCheck = Boolean(data?.healthCheckSwarm); + const hasHostPublishedPorts = + (data?.ports?.some((port) => port.publishMode === "host") || + data?.endpointSpecSwarm?.Ports?.some( + (port) => port.PublishMode === "host", + )) ?? + false; + const hasCustomServiceMode = Boolean( + data?.modeSwarm?.Global || + data?.modeSwarm?.ReplicatedJob || + data?.modeSwarm?.GlobalJob, + ); + const hasReplicatedModeOverride = Boolean(data?.modeSwarm?.Replicated); + const hasScalingOverride = hasCustomServiceMode || hasReplicatedModeOverride; + const isDirty = + instances !== effectiveInstances || strategy !== currentStrategy; + + useEffect(() => { + setInstances(effectiveInstances); + setStrategy(currentStrategy); + }, [effectiveInstances, currentStrategy]); + + const onSave = async () => { + if (instances < 1) { + toast.error("Instances must be at least 1"); + return; + } + + setIsSaving(true); + try { + await update({ + applicationId, + replicas: instances, + modeSwarm: null, + updateConfigSwarm: buildUpdateConfigSwarm( + data?.updateConfigSwarm, + strategy, + ), + }); + toast.success("Scaling and rollout settings updated. Redeploy to apply."); + await refetch(); + } catch { + toast.error("Error updating scaling and rollout settings"); + } finally { + setIsSaving(false); + } + }; + + return ( + + + Scaling & Rollouts + + Control application instances and whether deploys replace containers + before or after the new task starts. + + + +
+
+ + { + const nextValue = Number(event.target.value); + setInstances( + Number.isNaN(nextValue) ? 1 : Math.max(1, nextValue), + ); + }} + /> +

+ Uses simple replicated scaling for this application. +

+
+ +
+ + +

+ {strategy === "zero-downtime" + ? "Starts the replacement task first. Best results require a health check." + : "Stops the current task before the replacement starts."} +

+
+
+ + {strategy === "zero-downtime" && !hasHealthCheck && ( + + Zero downtime is best-effort without a health check. Configure one + in Advanced - Cluster Settings - Swarm Settings so Swarm knows when + the new task is actually ready. + + )} + + {strategy === "zero-downtime" && hasHostPublishedPorts && ( + + This application exposes one or more ports in host{" "} + mode. Start-first rollouts can still hit port-binding conflicts on a + node, so domain-routed traffic through Traefik is the safer path. + + )} + + {hasScalingOverride && ( + + This app has custom swarm service mode settings. Saving here will + switch it back to simple replicated scaling and use the Instances + value above. + + )} + + + Custom health checks, delays, rollback behavior, and other raw swarm + settings still live under Advanced - Cluster Settings. + + +
+
+

Current effective settings

+

+ {effectiveInstances} instance + {effectiveInstances === 1 ? "" : "s"} with{" "} + {currentStrategy === "zero-downtime" + ? "start-first" + : "stop-first"}{" "} + rollouts. +

+

+ Save changes here, then redeploy the application to apply them. +

+
+ {canUpdateService && ( + + )} +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/application/general/show.tsx b/apps/dokploy/components/dashboard/application/general/show.tsx index 01fc9e84ad..cb74834f89 100644 --- a/apps/dokploy/components/dashboard/application/general/show.tsx +++ b/apps/dokploy/components/dashboard/application/general/show.tsx @@ -11,6 +11,7 @@ import { useRouter } from "next/router"; import { toast } from "sonner"; import { ShowBuildChooseForm } from "@/components/dashboard/application/build/show"; import { ShowProviderForm } from "@/components/dashboard/application/general/generic/show"; +import { ShowScalingAndRollouts } from "@/components/dashboard/application/general/show-scaling-and-rollouts"; import { DialogAction } from "@/components/shared/dialog-action"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -329,6 +330,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => { )} +