Compare commits

...

14 Commits

Author SHA1 Message Date
Mauricio Siu
91183056f0 Merge pull request #1534 from Dokploy/feat/enable-swarm-overview
Feat/enable swarm overview
2025-03-19 00:52:22 -06:00
Mauricio Siu
03bd4398d0 chore(package): bump version to v0.20.8 2025-03-19 00:51:49 -06:00
Mauricio Siu
8c260eff72 feat(cluster): enhance AddNode and ShowNodes components for better user guidance and functionality
- Added an AlertBlock in AddNode to inform users about architecture compatibility when adding nodes.
- Updated ShowNodes to correctly handle node deletion actions based on ManagerStatus.
- Refactored cluster API to remove cloud-specific checks and improve command execution for remote servers.
2025-03-19 00:51:27 -06:00
Mauricio Siu
6e28196b0e chore(package): bump version to v0.20.7 2025-03-18 21:36:39 -06:00
Mauricio Siu
18bacae175 Merge pull request #1507 from nb5p/fix-alpine-linux-compatibility
fix(server-setup): resolve Alpine Linux compatibility issues
2025-03-18 21:35:43 -06:00
Mauricio Siu
f2be5a378e Merge pull request #1522 from ensarkurrt/canary
fix(ui): Improve Numeric Input Handling in Swarm Cluster Settings, Traefik Port Mappings, and Email Notifications
2025-03-18 21:27:20 -06:00
Mauricio Siu
aef24296b9 Merge pull request #1531 from Dokploy/fix/loader-swarm
Fix/loader swarm
2025-03-18 21:18:17 -06:00
Mauricio Siu
7123b9b109 feat(cluster): add error handling in AddManager and AddWorker components
- Integrated error handling in AddManager and AddWorker components to display error messages using AlertBlock when data fetching fails.
- Updated API query hooks to include error and isError states for improved user feedback during data operations.
2025-03-18 21:17:11 -06:00
Mauricio Siu
891dc840f5 feat(cluster): enhance node management UI with loading indicators and improved tab content
- Added loading indicators in AddManager and AddWorker components to enhance user experience during data fetching.
- Updated AddNode component to include overflow handling for tab content.
- Renamed "Show Nodes" to "Show Swarm Nodes" in ShowNodesModal for clarity.
2025-03-18 21:11:50 -06:00
Ensar Kurt
3cdf4c426c revert commit from #1513 2025-03-18 00:05:59 +03:00
Ensar Kurt
7cb184dc97 email notification port, last digit staying error fix 2025-03-17 23:48:17 +03:00
Ensar Kurt
fe57333f84 manage port inputs, default zero fix 2025-03-17 23:47:54 +03:00
Ensar Kurt
04fd77c3a9 replicas input cannot be zero and empty 2025-03-17 23:42:09 +03:00
nb5p
2974a8183e fix(server-setup): resolve Alpine Linux compatibility issues with setup scripts
Resolves #1482
2025-03-16 15:37:28 +08:00
12 changed files with 169 additions and 153 deletions

View File

@@ -40,7 +40,7 @@ interface Props {
} }
const AddRedirectchema = z.object({ const AddRedirectchema = z.object({
replicas: z.number(), replicas: z.number().min(1, "Replicas must be at least 1"),
registryId: z.string(), registryId: z.string(),
}); });
@@ -130,9 +130,11 @@ export const ShowClusterSettings = ({ applicationId }: Props) => {
placeholder="1" placeholder="1"
{...field} {...field}
onChange={(e) => { onChange={(e) => {
field.onChange(Number(e.target.value)); const value = e.target.value;
field.onChange(value === "" ? 0 : Number(value));
}} }}
type="number" type="number"
value={field.value || ""}
/> />
</FormControl> </FormControl>

View File

@@ -12,6 +12,7 @@ import { ExternalLink, PlusIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { AddManager } from "./manager/add-manager"; import { AddManager } from "./manager/add-manager";
import { AddWorker } from "./workers/add-worker"; import { AddWorker } from "./workers/add-worker";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props { interface Props {
serverId?: string; serverId?: string;
@@ -48,6 +49,10 @@ export const AddNode = ({ serverId }: Props) => {
Architecture Architecture
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
</Link> </Link>
<AlertBlock type="warning">
Make sure you use the same architecture as the node you are
adding.
</AlertBlock>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -56,10 +61,10 @@ export const AddNode = ({ serverId }: Props) => {
<TabsTrigger value="worker">Worker</TabsTrigger> <TabsTrigger value="worker">Worker</TabsTrigger>
<TabsTrigger value="manager">Manager</TabsTrigger> <TabsTrigger value="manager">Manager</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="worker" className="pt-4"> <TabsContent value="worker" className="pt-4 overflow-hidden">
<AddWorker serverId={serverId} /> <AddWorker serverId={serverId} />
</TabsContent> </TabsContent>
<TabsContent value="manager" className="pt-4"> <TabsContent value="manager" className="pt-4 overflow-hidden">
<AddManager serverId={serverId} /> <AddManager serverId={serverId} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>

View File

@@ -1,3 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CardContent } from "@/components/ui/card"; import { CardContent } from "@/components/ui/card";
import { import {
DialogDescription, DialogDescription,
@@ -6,7 +7,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { CopyIcon } from "lucide-react"; import { CopyIcon, Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
interface Props { interface Props {
@@ -14,56 +15,66 @@ interface Props {
} }
export const AddManager = ({ serverId }: Props) => { export const AddManager = ({ serverId }: Props) => {
const { data } = api.cluster.addManager.useQuery({ serverId }); const { data, isLoading, error, isError } = api.cluster.addManager.useQuery({
serverId,
});
return ( return (
<> <>
<div> <CardContent className="sm:max-w-4xl flex flex-col gap-4 px-0">
<CardContent className="sm:max-w-4xl max-h-screen overflow-y-auto flex flex-col gap-4 px-0"> <DialogHeader>
<DialogHeader> <DialogTitle>Add a new manager</DialogTitle>
<DialogTitle>Add a new manager</DialogTitle> <DialogDescription>Add a new manager</DialogDescription>
<DialogDescription>Add a new manager</DialogDescription> </DialogHeader>
</DialogHeader> {isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="flex flex-col gap-2.5 text-sm"> {isLoading ? (
<span>1. Go to your new server and run the following command</span> <Loader2 className="w-full animate-spin text-muted-foreground" />
<span className="bg-muted rounded-lg p-2 flex justify-between"> ) : (
curl https://get.docker.com | sh -s -- --version {data?.version} <>
<button <div className="flex flex-col gap-2.5 text-sm">
type="button" <span>
className="self-center" 1. Go to your new server and run the following command
onClick={() => { </span>
copy( <span className="bg-muted rounded-lg p-2 flex justify-between">
`curl https://get.docker.com | sh -s -- --version ${data?.version}`, curl https://get.docker.com | sh -s -- --version {data?.version}
); <button
toast.success("Copied to clipboard"); type="button"
}} className="self-center"
> onClick={() => {
<CopyIcon className="h-4 w-4 cursor-pointer" /> copy(
</button> `curl https://get.docker.com | sh -s -- --version ${data?.version}`,
</span> );
</div> toast.success("Copied to clipboard");
}}
>
<CopyIcon className="h-4 w-4 cursor-pointer" />
</button>
</span>
</div>
<div className="flex flex-col gap-2.5 text-sm"> <div className="flex flex-col gap-2.5 text-sm">
<span> <span>
2. Run the following command to add the node(manager) to your 2. Run the following command to add the node(manager) to your
cluster cluster
</span> </span>
<span className="bg-muted rounded-lg p-2 flex">
{data?.command} <span className="bg-muted rounded-lg p-2 flex">
<button {data?.command}
type="button" <button
className="self-start" type="button"
onClick={() => { className="self-start"
copy(data?.command || ""); onClick={() => {
toast.success("Copied to clipboard"); copy(data?.command || "");
}} toast.success("Copied to clipboard");
> }}
<CopyIcon className="h-4 w-4 cursor-pointer" /> >
</button> <CopyIcon className="h-4 w-4 cursor-pointer" />
</span> </button>
</div> </span>
</CardContent> </div>
</div> </>
)}
</CardContent>
</> </>
); );
}; };

View File

@@ -17,7 +17,7 @@ export const ShowNodesModal = ({ serverId }: Props) => {
className="w-full cursor-pointer " className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()} onSelect={(e) => e.preventDefault()}
> >
Show Nodes Show Swarm Nodes
</DropdownMenuItem> </DropdownMenuItem>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-5xl overflow-y-auto max-h-screen "> <DialogContent className="sm:max-w-5xl overflow-y-auto max-h-screen ">

View File

@@ -144,7 +144,7 @@ export const ShowNodes = ({ serverId }: Props) => {
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuLabel>Actions</DropdownMenuLabel>
<ShowNodeData data={node} /> <ShowNodeData data={node} />
{node?.ManagerStatus?.Leader && ( {!node?.ManagerStatus?.Leader && (
<DialogAction <DialogAction
title="Delete Node" title="Delete Node"
description="Are you sure you want to delete this node from the cluster?" description="Are you sure you want to delete this node from the cluster?"

View File

@@ -1,3 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CardContent } from "@/components/ui/card"; import { CardContent } from "@/components/ui/card";
import { import {
DialogDescription, DialogDescription,
@@ -6,7 +7,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { CopyIcon } from "lucide-react"; import { CopyIcon, Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
interface Props { interface Props {
@@ -14,54 +15,62 @@ interface Props {
} }
export const AddWorker = ({ serverId }: Props) => { export const AddWorker = ({ serverId }: Props) => {
const { data } = api.cluster.addWorker.useQuery({ serverId }); const { data, isLoading, error, isError } = api.cluster.addWorker.useQuery({
serverId,
});
return ( return (
<div> <CardContent className="sm:max-w-4xl flex flex-col gap-4 px-0">
<CardContent className="sm:max-w-4xl max-h-screen overflow-y-auto flex flex-col gap-4 px-0"> <DialogHeader>
<DialogHeader> <DialogTitle>Add a new worker</DialogTitle>
<DialogTitle>Add a new worker</DialogTitle> <DialogDescription>Add a new worker</DialogDescription>
<DialogDescription>Add a new worker</DialogDescription> </DialogHeader>
</DialogHeader> {isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="flex flex-col gap-2.5 text-sm"> {isLoading ? (
<span>1. Go to your new server and run the following command</span> <Loader2 className="w-full animate-spin text-muted-foreground" />
<span className="bg-muted rounded-lg p-2 flex justify-between"> ) : (
curl https://get.docker.com | sh -s -- --version {data?.version} <>
<button <div className="flex flex-col gap-2.5 text-sm">
type="button" <span>1. Go to your new server and run the following command</span>
className="self-center" <span className="bg-muted rounded-lg p-2 flex justify-between">
onClick={() => { curl https://get.docker.com | sh -s -- --version {data?.version}
copy( <button
`curl https://get.docker.com | sh -s -- --version ${data?.version}`, type="button"
); className="self-center"
toast.success("Copied to clipboard"); onClick={() => {
}} copy(
> `curl https://get.docker.com | sh -s -- --version ${data?.version}`,
<CopyIcon className="h-4 w-4 cursor-pointer" /> );
</button> toast.success("Copied to clipboard");
</span> }}
</div> >
<CopyIcon className="h-4 w-4 cursor-pointer" />
</button>
</span>
</div>
<div className="flex flex-col gap-2.5 text-sm"> <div className="flex flex-col gap-2.5 text-sm">
<span> <span>
2. Run the following command to add the node(worker) to your cluster 2. Run the following command to add the node(worker) to your
</span> cluster
</span>
<span className="bg-muted rounded-lg p-2 flex"> <span className="bg-muted rounded-lg p-2 flex">
{data?.command} {data?.command}
<button <button
type="button" type="button"
className="self-start" className="self-start"
onClick={() => { onClick={() => {
copy(data?.command || ""); copy(data?.command || "");
toast.success("Copied to clipboard"); toast.success("Copied to clipboard");
}} }}
> >
<CopyIcon className="h-4 w-4 cursor-pointer" /> <CopyIcon className="h-4 w-4 cursor-pointer" />
</button> </button>
</span> </span>
</div> </div>
</CardContent> </>
</div> )}
</CardContent>
); );
}; };

View File

@@ -663,13 +663,16 @@ export const HandleNotifications = ({ notificationId }: Props) => {
{...field} {...field}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
if (value) { if (value === "") {
field.onChange(undefined);
} else {
const port = Number.parseInt(value); const port = Number.parseInt(value);
if (port > 0 && port < 65536) { if (port > 0 && port < 65536) {
field.onChange(port); field.onChange(port);
} }
} }
}} }}
value={field.value || ""}
type="number" type="number"
/> />
</FormControl> </FormControl>

View File

@@ -159,9 +159,11 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
<Input <Input
type="number" type="number"
{...field} {...field}
onChange={(e) => onChange={(e) => {
field.onChange(Number(e.target.value)) const value = e.target.value;
} field.onChange(value === "" ? undefined : Number(value));
}}
value={field.value || ""}
className="w-full dark:bg-black" className="w-full dark:bg-black"
placeholder="e.g. 8080" placeholder="e.g. 8080"
/> />
@@ -185,9 +187,11 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
<Input <Input
type="number" type="number"
{...field} {...field}
onChange={(e) => onChange={(e) => {
field.onChange(Number(e.target.value)) const value = e.target.value;
} field.onChange(value === "" ? undefined : Number(value));
}}
value={field.value || ""}
className="w-full dark:bg-black" className="w-full dark:bg-black"
placeholder="e.g. 80" placeholder="e.g. 80"
/> />

View File

@@ -39,7 +39,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, InputProps>(
className={cn("text-left", className)} className={cn("text-left", className)}
ref={ref} ref={ref}
{...props} {...props}
value={props.value === undefined || props.value === "" ? "" : String(props.value)} value={props.value === undefined ? undefined : String(props.value)}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
if (value === "") { if (value === "") {
@@ -60,21 +60,6 @@ const NumberInput = React.forwardRef<HTMLInputElement, InputProps>(
} }
} }
}} }}
onBlur={(e) => {
// If input is empty, make 0 when focus is lost
if (e.target.value === "") {
const syntheticEvent = {
...e,
target: {
...e.target,
value: "0",
},
};
props.onChange?.(
syntheticEvent as unknown as React.ChangeEvent<HTMLInputElement>,
);
}
}}
/> />
); );
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "dokploy", "name": "dokploy",
"version": "v0.20.6", "version": "v0.20.8",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",

View File

@@ -1,8 +1,9 @@
import { getPublicIpWithFallback } from "@/server/wss/terminal"; import { getPublicIpWithFallback } from "@/server/wss/terminal";
import { import {
type DockerNode, type DockerNode,
IS_CLOUD,
execAsync, execAsync,
execAsyncRemote,
findServerById,
getRemoteDocker, getRemoteDocker,
} from "@dokploy/server"; } from "@dokploy/server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@@ -16,10 +17,6 @@ export const clusterRouter = createTRPCRouter({
}), }),
) )
.query(async ({ input }) => { .query(async ({ input }) => {
if (IS_CLOUD) {
return [];
}
const docker = await getRemoteDocker(input.serverId); const docker = await getRemoteDocker(input.serverId);
const workers: DockerNode[] = await docker.listNodes(); const workers: DockerNode[] = await docker.listNodes();
@@ -33,17 +30,17 @@ export const clusterRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
if (IS_CLOUD) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Functionality not available in cloud version",
});
}
try { try {
await execAsync( const drainCommand = `docker node update --availability drain ${input.nodeId}`;
`docker node update --availability drain ${input.nodeId}`, const removeCommand = `docker node rm ${input.nodeId} --force`;
);
await execAsync(`docker node rm ${input.nodeId} --force`); if (input.serverId) {
await execAsyncRemote(input.serverId, drainCommand);
await execAsyncRemote(input.serverId, removeCommand);
} else {
await execAsync(drainCommand);
await execAsync(removeCommand);
}
return true; return true;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
@@ -60,20 +57,20 @@ export const clusterRouter = createTRPCRouter({
}), }),
) )
.query(async ({ input }) => { .query(async ({ input }) => {
if (IS_CLOUD) {
return {
command: "",
version: "",
};
}
const docker = await getRemoteDocker(input.serverId); const docker = await getRemoteDocker(input.serverId);
const result = await docker.swarmInspect(); const result = await docker.swarmInspect();
const docker_version = await docker.version(); const docker_version = await docker.version();
let ip = await getPublicIpWithFallback();
if (input.serverId) {
const server = await findServerById(input.serverId);
ip = server?.ipAddress;
}
return { return {
command: `docker swarm join --token ${ command: `docker swarm join --token ${
result.JoinTokens.Worker result.JoinTokens.Worker
} ${await getPublicIpWithFallback()}:2377`, } ${ip}:2377`,
version: docker_version.Version, version: docker_version.Version,
}; };
}), }),
@@ -84,19 +81,19 @@ export const clusterRouter = createTRPCRouter({
}), }),
) )
.query(async ({ input }) => { .query(async ({ input }) => {
if (IS_CLOUD) {
return {
command: "",
version: "",
};
}
const docker = await getRemoteDocker(input.serverId); const docker = await getRemoteDocker(input.serverId);
const result = await docker.swarmInspect(); const result = await docker.swarmInspect();
const docker_version = await docker.version(); const docker_version = await docker.version();
let ip = await getPublicIpWithFallback();
if (input.serverId) {
const server = await findServerById(input.serverId);
ip = server?.ipAddress;
}
return { return {
command: `docker swarm join --token ${ command: `docker swarm join --token ${
result.JoinTokens.Manager result.JoinTokens.Manager
} ${await getPublicIpWithFallback()}:2377`, } ${ip}:2377`,
version: docker_version.Version, version: docker_version.Version,
}; };
}), }),

View File

@@ -361,7 +361,7 @@ const installUtilities = () => `
alpine) alpine)
sed -i '/^#.*\/community/s/^#//' /etc/apk/repositories sed -i '/^#.*\/community/s/^#//' /etc/apk/repositories
apk update >/dev/null apk update >/dev/null
apk add curl wget git jq openssl >/dev/null apk add curl wget git jq openssl sudo unzip tar >/dev/null
;; ;;
ubuntu | debian | raspbian) ubuntu | debian | raspbian)
DEBIAN_FRONTEND=noninteractive apt-get update -y >/dev/null DEBIAN_FRONTEND=noninteractive apt-get update -y >/dev/null