Skip to content

Commit 9a7ed91

Browse files
committed
feat: add validate server
1 parent bf9abbc commit 9a7ed91

File tree

6 files changed

+281
-5
lines changed

6 files changed

+281
-5
lines changed

apps/dokploy/components/dashboard/settings/servers/setup-server.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { useState } from "react";
3333
import { toast } from "sonner";
3434
import { ShowDeployment } from "../../application/deployments/show-deployment";
3535
import { GPUSupport } from "./gpu-support";
36+
import { ValidateServer } from "./validate-server";
3637

3738
interface Props {
3839
serverId: string;
@@ -90,9 +91,10 @@ export const SetupServer = ({ serverId }: Props) => {
9091
) : (
9192
<div id="hook-form-add-gitlab" className="grid w-full gap-1">
9293
<Tabs defaultValue="ssh-keys">
93-
<TabsList className="grid grid-cols-3 w-[400px]">
94+
<TabsList className="grid grid-cols-4 w-[600px]">
9495
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
9596
<TabsTrigger value="deployments">Deployments</TabsTrigger>
97+
<TabsTrigger value="validate">Validate</TabsTrigger>
9698
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
9799
</TabsList>
98100
<TabsContent
@@ -203,7 +205,7 @@ export const SetupServer = ({ serverId }: Props) => {
203205
<div className="flex flex-col gap-4">
204206
<Card className="bg-background">
205207
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
206-
<div className="flex flex-row gap-2 justify-between w-full items-end max-sm:flex-col">
208+
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
207209
<div className="flex flex-col gap-1">
208210
<CardTitle className="text-xl">
209211
Deployments
@@ -293,6 +295,14 @@ export const SetupServer = ({ serverId }: Props) => {
293295
</div>
294296
</CardContent>
295297
</TabsContent>
298+
<TabsContent
299+
value="validate"
300+
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
301+
>
302+
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
303+
<ValidateServer serverId={serverId} />
304+
</div>
305+
</TabsContent>
296306
<TabsContent
297307
value="gpu-setup"
298308
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { AlertBlock } from "@/components/shared/alert-block";
2+
import {
3+
Card,
4+
CardContent,
5+
CardDescription,
6+
CardHeader,
7+
CardTitle,
8+
} from "@/components/ui/card";
9+
import { api } from "@/utils/api";
10+
import { Loader2, PcCase, RefreshCw } from "lucide-react";
11+
import { StatusRow } from "./gpu-support";
12+
import { Button } from "@/components/ui/button";
13+
import { useState } from "react";
14+
15+
interface Props {
16+
serverId: string;
17+
}
18+
19+
export const ValidateServer = ({ serverId }: Props) => {
20+
const [isRefreshing, setIsRefreshing] = useState(false);
21+
const { data, refetch, error, isLoading, isError } =
22+
api.server.validate.useQuery(
23+
{ serverId },
24+
{
25+
enabled: !!serverId,
26+
},
27+
);
28+
const utils = api.useUtils();
29+
return (
30+
<CardContent className="p-0">
31+
<div className="flex flex-col gap-4">
32+
<Card className="bg-background">
33+
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
34+
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
35+
<div className="flex flex-col gap-1">
36+
<div className="flex items-center gap-2">
37+
<PcCase className="size-5" />
38+
<CardTitle className="text-xl">Setup Validation</CardTitle>
39+
</div>
40+
<CardDescription>
41+
Check if your server is ready for deployment
42+
</CardDescription>
43+
</div>
44+
<Button
45+
isLoading={isRefreshing}
46+
onClick={async () => {
47+
setIsRefreshing(true);
48+
await refetch();
49+
setIsRefreshing(false);
50+
}}
51+
>
52+
<RefreshCw className="size-4" />
53+
Refresh
54+
</Button>
55+
</div>
56+
<div className="flex items-center gap-2 w-full">
57+
{isError && (
58+
<AlertBlock type="error" className="w-full">
59+
{error.message}
60+
</AlertBlock>
61+
)}
62+
</div>
63+
</CardHeader>
64+
65+
<CardContent className="flex flex-col gap-4">
66+
{isLoading ? (
67+
<div className="flex items-center justify-center text-muted-foreground py-4">
68+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
69+
<span>Checking Server Configuration</span>
70+
</div>
71+
) : (
72+
<div className="grid w-full gap-4">
73+
<div className="border rounded-lg p-4">
74+
<h3 className="text-lg font-semibold mb-1">Status</h3>
75+
<p className="text-sm text-muted-foreground mb-4">
76+
Shows the configuration state that changes with the Enable
77+
GPU
78+
</p>
79+
<div className="grid gap-2.5">
80+
<StatusRow
81+
label="Docker Installed"
82+
isEnabled={data?.isDockerInstalled}
83+
/>
84+
<StatusRow
85+
label="RClone Installed"
86+
isEnabled={data?.isRCloneInstalled}
87+
/>
88+
<StatusRow
89+
label="Nixpacks Installed"
90+
isEnabled={data?.isNixpacksInstalled}
91+
/>
92+
<StatusRow
93+
label="Buildpacks Installed"
94+
isEnabled={data?.isBuildpacksInstalled}
95+
/>
96+
<StatusRow
97+
label="Swarm Installed"
98+
isEnabled={data?.isSwarmInstalled}
99+
/>
100+
<StatusRow
101+
label="Main Directory Created"
102+
isEnabled={data?.isMainDirectoryInstalled}
103+
/>
104+
</div>
105+
</div>
106+
</div>
107+
)}
108+
</CardContent>
109+
</Card>
110+
</div>
111+
</CardContent>
112+
);
113+
};

apps/dokploy/server/api/routers/server.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
haveActiveServices,
2727
removeDeploymentsByServerId,
2828
serverSetup,
29+
serverValidate,
2930
updateServerById,
3031
} from "@dokploy/server";
3132
import { TRPCError } from "@trpc/server";
@@ -118,6 +119,34 @@ export const serverRouter = createTRPCRouter({
118119
throw error;
119120
}
120121
}),
122+
validate: protectedProcedure
123+
.input(apiFindOneServer)
124+
.query(async ({ input, ctx }) => {
125+
try {
126+
const server = await findServerById(input.serverId);
127+
if (server.adminId !== ctx.user.adminId) {
128+
throw new TRPCError({
129+
code: "UNAUTHORIZED",
130+
message: "You are not authorized to validate this server",
131+
});
132+
}
133+
const response = await serverValidate(input.serverId);
134+
return response as unknown as {
135+
isDockerInstalled: boolean;
136+
isRCloneInstalled: boolean;
137+
isSwarmInstalled: boolean;
138+
isNixpacksInstalled: boolean;
139+
isBuildpacksInstalled: boolean;
140+
isMainDirectoryInstalled: boolean;
141+
};
142+
} catch (error) {
143+
throw new TRPCError({
144+
code: "BAD_REQUEST",
145+
message: error instanceof Error ? error?.message : `Error: ${error}`,
146+
cause: error as Error,
147+
});
148+
}
149+
}),
121150
remove: protectedProcedure
122151
.input(apiRemoveServer)
123152
.mutation(async ({ input, ctx }) => {

packages/server/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export * from "./setup/redis-setup";
4141
export * from "./setup/server-setup";
4242
export * from "./setup/setup";
4343
export * from "./setup/traefik-setup";
44+
export * from "./setup/server-validate";
4445

4546
export * from "./utils/backups/index";
4647
export * from "./utils/backups/mariadb";

packages/server/src/setup/server-setup.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,14 +132,16 @@ const installRequirements = async (serverId: string, logPath: string) => {
132132
echo -e "---------------------------------------------\n"
133133
echo -e "1. Installing required packages (curl, wget, git, jq, openssl). "
134134
135+
command_exists() {
136+
command -v "$@" > /dev/null 2>&1
137+
}
138+
135139
${installUtilities()}
136140
137141
echo -e "2. Validating ports. "
138142
${validatePorts()}
139143
140-
command_exists() {
141-
command -v "$@" > /dev/null 2>&1
142-
}
144+
143145
144146
echo -e "3. Installing RClone. "
145147
${installRClone()}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { Client } from "ssh2";
2+
import { findServerById } from "../services/server";
3+
4+
export const validateDocker = () => `
5+
if command_exists docker; then
6+
echo true
7+
else
8+
echo false
9+
fi
10+
`;
11+
12+
export const validateRClone = () => `
13+
if command_exists rclone; then
14+
echo true
15+
else
16+
echo false
17+
fi
18+
`;
19+
20+
export const validateSwarm = () => `
21+
if docker info --format '{{.Swarm.LocalNodeState}}' | grep -q 'active'; then
22+
echo true
23+
else
24+
echo false
25+
fi
26+
`;
27+
28+
export const validateNixpacks = () => `
29+
if command_exists nixpacks; then
30+
echo true
31+
else
32+
echo false
33+
fi
34+
`;
35+
36+
export const validateBuildpacks = () => `
37+
if command_exists pack; then
38+
echo true
39+
else
40+
echo false
41+
fi
42+
`;
43+
44+
export const validateMainDirectory = () => `
45+
if [ -d "/etc/dokploy" ]; then
46+
echo true
47+
else
48+
echo false
49+
fi
50+
`;
51+
export const serverValidate = async (serverId: string) => {
52+
const client = new Client();
53+
const server = await findServerById(serverId);
54+
if (!server.sshKeyId) {
55+
throw new Error("No SSH Key found");
56+
}
57+
58+
return new Promise<string>((resolve, reject) => {
59+
client
60+
.once("ready", () => {
61+
const bashCommand = `
62+
command_exists() {
63+
command -v "$@" > /dev/null 2>&1
64+
}
65+
66+
isDockerInstalled=$(${validateDocker()})
67+
isRCloneInstalled=$(${validateRClone()})
68+
isSwarmInstalled=$(${validateSwarm()})
69+
isNixpacksInstalled=$(${validateNixpacks()})
70+
isBuildpacksInstalled=$(${validateBuildpacks()})
71+
isMainDirectoryInstalled=$(${validateMainDirectory()})
72+
73+
echo "{\\"isDockerInstalled\\": $isDockerInstalled, \\"isRCloneInstalled\\": $isRCloneInstalled, \\"isSwarmInstalled\\": $isSwarmInstalled, \\"isNixpacksInstalled\\": $isNixpacksInstalled, \\"isBuildpacksInstalled\\": $isBuildpacksInstalled, \\"isMainDirectoryInstalled\\": $isMainDirectoryInstalled}"
74+
`;
75+
client.exec(bashCommand, (err, stream) => {
76+
if (err) {
77+
reject(err);
78+
return;
79+
}
80+
let output = "";
81+
stream
82+
.on("close", () => {
83+
client.end();
84+
try {
85+
const result = JSON.parse(output.trim());
86+
resolve(result);
87+
} catch (parseError) {
88+
reject(
89+
new Error(
90+
`Failed to parse output: ${parseError instanceof Error ? parseError.message : parseError}`,
91+
),
92+
);
93+
}
94+
})
95+
.on("data", (data: string) => {
96+
output += data;
97+
})
98+
.stderr.on("data", (data) => {});
99+
});
100+
})
101+
.on("error", (err) => {
102+
client.end();
103+
if (err.level === "client-authentication") {
104+
reject(
105+
new Error(
106+
`Authentication failed: Invalid SSH private key. ❌ Error: ${err.message} ${err.level}`,
107+
),
108+
);
109+
} else {
110+
reject(new Error(`SSH connection error: ${err.message}`));
111+
}
112+
})
113+
.connect({
114+
host: server.ipAddress,
115+
port: server.port,
116+
username: server.username,
117+
privateKey: server.sshKey?.privateKey,
118+
timeout: 99999,
119+
});
120+
});
121+
};

0 commit comments

Comments
 (0)