Skip to content

Commit 0d129fc

Browse files
committed
feat(application): add scaling and rollout controls
1 parent 6e342ee commit 0d129fc

2 files changed

Lines changed: 251 additions & 0 deletions

File tree

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { useEffect, useState } from "react";
2+
import { toast } from "sonner";
3+
import { AlertBlock } from "@/components/shared/alert-block";
4+
import { Button } from "@/components/ui/button";
5+
import {
6+
Card,
7+
CardContent,
8+
CardDescription,
9+
CardHeader,
10+
CardTitle,
11+
} from "@/components/ui/card";
12+
import { Input } from "@/components/ui/input";
13+
import { Label } from "@/components/ui/label";
14+
import {
15+
Select,
16+
SelectContent,
17+
SelectItem,
18+
SelectTrigger,
19+
SelectValue,
20+
} from "@/components/ui/select";
21+
import { api } from "@/utils/api";
22+
23+
interface Props {
24+
applicationId: string;
25+
}
26+
27+
type DeploymentStrategy = "standard" | "zero-downtime";
28+
29+
type UpdateConfigSwarm = {
30+
Parallelism?: number;
31+
Delay?: number;
32+
FailureAction?: string;
33+
Monitor?: number;
34+
MaxFailureRatio?: number;
35+
Order?: string;
36+
} | null;
37+
38+
const getDeploymentStrategy = (
39+
updateConfigSwarm: UpdateConfigSwarm | undefined,
40+
): DeploymentStrategy =>
41+
updateConfigSwarm?.Order === "stop-first" ? "standard" : "zero-downtime";
42+
43+
const getEffectiveInstances = (
44+
replicas?: number,
45+
modeSwarm?: {
46+
Replicated?: { Replicas?: number };
47+
} | null,
48+
) => modeSwarm?.Replicated?.Replicas ?? replicas ?? 1;
49+
50+
const buildUpdateConfigSwarm = (
51+
currentUpdateConfigSwarm: UpdateConfigSwarm | undefined,
52+
strategy: DeploymentStrategy,
53+
) => {
54+
const baseConfig = currentUpdateConfigSwarm ?? {
55+
FailureAction: "rollback",
56+
Parallelism: 1,
57+
};
58+
59+
return {
60+
...baseConfig,
61+
Parallelism: currentUpdateConfigSwarm?.Parallelism ?? 1,
62+
Order: strategy === "standard" ? "stop-first" : "start-first",
63+
};
64+
};
65+
66+
export const ShowScalingAndRollouts = ({ applicationId }: Props) => {
67+
const { data: permissions } = api.user.getPermissions.useQuery();
68+
const canUpdateService = permissions?.service.create ?? false;
69+
const { data, refetch } = api.application.one.useQuery(
70+
{
71+
applicationId,
72+
},
73+
{ enabled: !!applicationId },
74+
);
75+
const { mutateAsync: update } = api.application.update.useMutation();
76+
const [instances, setInstances] = useState(1);
77+
const [strategy, setStrategy] = useState<DeploymentStrategy>("zero-downtime");
78+
const [isSaving, setIsSaving] = useState(false);
79+
80+
const effectiveInstances = getEffectiveInstances(
81+
data?.replicas,
82+
data?.modeSwarm,
83+
);
84+
const currentStrategy = getDeploymentStrategy(data?.updateConfigSwarm);
85+
const hasHealthCheck = Boolean(data?.healthCheckSwarm);
86+
const hasHostPublishedPorts =
87+
(data?.ports?.some((port) => port.publishMode === "host") ||
88+
data?.endpointSpecSwarm?.Ports?.some(
89+
(port) => port.PublishMode === "host",
90+
)) ??
91+
false;
92+
const hasCustomServiceMode = Boolean(
93+
data?.modeSwarm?.Global ||
94+
data?.modeSwarm?.ReplicatedJob ||
95+
data?.modeSwarm?.GlobalJob,
96+
);
97+
const hasReplicatedModeOverride = Boolean(data?.modeSwarm?.Replicated);
98+
const hasScalingOverride = hasCustomServiceMode || hasReplicatedModeOverride;
99+
const isDirty =
100+
instances !== effectiveInstances || strategy !== currentStrategy;
101+
102+
useEffect(() => {
103+
setInstances(effectiveInstances);
104+
setStrategy(currentStrategy);
105+
}, [effectiveInstances, currentStrategy]);
106+
107+
const onSave = async () => {
108+
if (instances < 1) {
109+
toast.error("Instances must be at least 1");
110+
return;
111+
}
112+
113+
setIsSaving(true);
114+
try {
115+
await update({
116+
applicationId,
117+
replicas: instances,
118+
modeSwarm: null,
119+
updateConfigSwarm: buildUpdateConfigSwarm(
120+
data?.updateConfigSwarm,
121+
strategy,
122+
),
123+
});
124+
toast.success("Scaling and rollout settings updated. Redeploy to apply.");
125+
await refetch();
126+
} catch {
127+
toast.error("Error updating scaling and rollout settings");
128+
} finally {
129+
setIsSaving(false);
130+
}
131+
};
132+
133+
return (
134+
<Card className="bg-background">
135+
<CardHeader>
136+
<CardTitle className="text-xl">Scaling & Rollouts</CardTitle>
137+
<CardDescription>
138+
Control application instances and whether deploys replace containers
139+
before or after the new task starts.
140+
</CardDescription>
141+
</CardHeader>
142+
<CardContent className="space-y-4">
143+
<div className="grid gap-4 md:grid-cols-2">
144+
<div className="space-y-2">
145+
<Label htmlFor="application-instances">Instances</Label>
146+
<Input
147+
id="application-instances"
148+
type="number"
149+
min={1}
150+
value={instances}
151+
disabled={!canUpdateService}
152+
onChange={(event) => {
153+
const nextValue = Number(event.target.value);
154+
setInstances(
155+
Number.isNaN(nextValue) ? 1 : Math.max(1, nextValue),
156+
);
157+
}}
158+
/>
159+
<p className="text-sm text-muted-foreground">
160+
Uses simple replicated scaling for this application.
161+
</p>
162+
</div>
163+
164+
<div className="space-y-2">
165+
<Label htmlFor="application-deployment-strategy">
166+
Deployment Strategy
167+
</Label>
168+
<Select
169+
value={strategy}
170+
disabled={!canUpdateService}
171+
onValueChange={(value) =>
172+
setStrategy(value as DeploymentStrategy)
173+
}
174+
>
175+
<SelectTrigger id="application-deployment-strategy">
176+
<SelectValue placeholder="Select a deployment strategy" />
177+
</SelectTrigger>
178+
<SelectContent>
179+
<SelectItem value="standard">Standard</SelectItem>
180+
<SelectItem value="zero-downtime">Zero Downtime</SelectItem>
181+
</SelectContent>
182+
</Select>
183+
<p className="text-sm text-muted-foreground">
184+
{strategy === "zero-downtime"
185+
? "Starts the replacement task first. Best results require a health check."
186+
: "Stops the current task before the replacement starts."}
187+
</p>
188+
</div>
189+
</div>
190+
191+
{strategy === "zero-downtime" && !hasHealthCheck && (
192+
<AlertBlock type="warning">
193+
Zero downtime is best-effort without a health check. Configure one
194+
in Advanced - Cluster Settings - Swarm Settings so Swarm knows when
195+
the new task is actually ready.
196+
</AlertBlock>
197+
)}
198+
199+
{strategy === "zero-downtime" && hasHostPublishedPorts && (
200+
<AlertBlock type="warning">
201+
This application exposes one or more ports in <code>host</code>{" "}
202+
mode. Start-first rollouts can still hit port-binding conflicts on a
203+
node, so domain-routed traffic through Traefik is the safer path.
204+
</AlertBlock>
205+
)}
206+
207+
{hasScalingOverride && (
208+
<AlertBlock type="info">
209+
This app has custom swarm service mode settings. Saving here will
210+
switch it back to simple replicated scaling and use the Instances
211+
value above.
212+
</AlertBlock>
213+
)}
214+
215+
<AlertBlock type="info">
216+
Custom health checks, delays, rollback behavior, and other raw swarm
217+
settings still live under Advanced - Cluster Settings.
218+
</AlertBlock>
219+
220+
<div className="flex flex-col gap-3 rounded-lg border p-4 md:flex-row md:items-center md:justify-between">
221+
<div className="space-y-1">
222+
<p className="text-sm font-medium">Current effective settings</p>
223+
<p className="text-sm text-muted-foreground">
224+
{effectiveInstances} instance
225+
{effectiveInstances === 1 ? "" : "s"} with{" "}
226+
{currentStrategy === "zero-downtime"
227+
? "start-first"
228+
: "stop-first"}{" "}
229+
rollouts.
230+
</p>
231+
<p className="text-sm text-muted-foreground">
232+
Save changes here, then redeploy the application to apply them.
233+
</p>
234+
</div>
235+
{canUpdateService && (
236+
<Button
237+
type="button"
238+
onClick={onSave}
239+
isLoading={isSaving}
240+
disabled={!isDirty || isSaving}
241+
>
242+
Save Rollout Settings
243+
</Button>
244+
)}
245+
</div>
246+
</CardContent>
247+
</Card>
248+
);
249+
};

apps/dokploy/components/dashboard/application/general/show.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useRouter } from "next/router";
1111
import { toast } from "sonner";
1212
import { ShowBuildChooseForm } from "@/components/dashboard/application/build/show";
1313
import { ShowProviderForm } from "@/components/dashboard/application/general/generic/show";
14+
import { ShowScalingAndRollouts } from "@/components/dashboard/application/general/show-scaling-and-rollouts";
1415
import { DialogAction } from "@/components/shared/dialog-action";
1516
import { Button } from "@/components/ui/button";
1617
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -329,6 +330,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
329330
)}
330331
</CardContent>
331332
</Card>
333+
<ShowScalingAndRollouts applicationId={applicationId} />
332334
<ShowProviderForm applicationId={applicationId} />
333335
<ShowBuildChooseForm applicationId={applicationId} />
334336
</>

0 commit comments

Comments
 (0)