mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Merge branch 'Dokploy:canary' into canary
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
const baseAdmin: User = {
|
||||
https: false,
|
||||
enablePaidFeatures: false,
|
||||
metricsConfig: {
|
||||
containers: {
|
||||
@@ -73,7 +74,6 @@ beforeEach(() => {
|
||||
|
||||
test("Should read the configuration file", () => {
|
||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||
|
||||
expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe(
|
||||
"dokploy-service-app",
|
||||
);
|
||||
@@ -83,6 +83,7 @@ test("Should apply redirect-to-https", () => {
|
||||
updateServerTraefik(
|
||||
{
|
||||
...baseAdmin,
|
||||
https: true,
|
||||
certificateType: "letsencrypt",
|
||||
},
|
||||
"example.com",
|
||||
|
||||
61
apps/dokploy/__test__/utils/backups.test.ts
Normal file
61
apps/dokploy/__test__/utils/backups.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { normalizeS3Path } from "@dokploy/server/utils/backups/utils";
|
||||
|
||||
describe("normalizeS3Path", () => {
|
||||
test("should handle empty and whitespace-only prefix", () => {
|
||||
expect(normalizeS3Path("")).toBe("");
|
||||
expect(normalizeS3Path("/")).toBe("");
|
||||
expect(normalizeS3Path(" ")).toBe("");
|
||||
expect(normalizeS3Path("\t")).toBe("");
|
||||
expect(normalizeS3Path("\n")).toBe("");
|
||||
expect(normalizeS3Path(" \n \t ")).toBe("");
|
||||
});
|
||||
|
||||
test("should trim whitespace from prefix", () => {
|
||||
expect(normalizeS3Path(" prefix")).toBe("prefix/");
|
||||
expect(normalizeS3Path("prefix ")).toBe("prefix/");
|
||||
expect(normalizeS3Path(" prefix ")).toBe("prefix/");
|
||||
expect(normalizeS3Path("\tprefix\t")).toBe("prefix/");
|
||||
expect(normalizeS3Path(" prefix/nested ")).toBe("prefix/nested/");
|
||||
});
|
||||
|
||||
test("should remove leading slashes", () => {
|
||||
expect(normalizeS3Path("/prefix")).toBe("prefix/");
|
||||
expect(normalizeS3Path("///prefix")).toBe("prefix/");
|
||||
});
|
||||
|
||||
test("should remove trailing slashes", () => {
|
||||
expect(normalizeS3Path("prefix/")).toBe("prefix/");
|
||||
expect(normalizeS3Path("prefix///")).toBe("prefix/");
|
||||
});
|
||||
|
||||
test("should remove both leading and trailing slashes", () => {
|
||||
expect(normalizeS3Path("/prefix/")).toBe("prefix/");
|
||||
expect(normalizeS3Path("///prefix///")).toBe("prefix/");
|
||||
});
|
||||
|
||||
test("should handle nested paths", () => {
|
||||
expect(normalizeS3Path("prefix/nested")).toBe("prefix/nested/");
|
||||
expect(normalizeS3Path("/prefix/nested/")).toBe("prefix/nested/");
|
||||
expect(normalizeS3Path("///prefix/nested///")).toBe("prefix/nested/");
|
||||
});
|
||||
|
||||
test("should preserve middle slashes", () => {
|
||||
expect(normalizeS3Path("prefix/nested/deep")).toBe("prefix/nested/deep/");
|
||||
expect(normalizeS3Path("/prefix/nested/deep/")).toBe("prefix/nested/deep/");
|
||||
});
|
||||
|
||||
test("should handle special characters", () => {
|
||||
expect(normalizeS3Path("prefix-with-dashes")).toBe("prefix-with-dashes/");
|
||||
expect(normalizeS3Path("prefix_with_underscores")).toBe(
|
||||
"prefix_with_underscores/",
|
||||
);
|
||||
expect(normalizeS3Path("prefix.with.dots")).toBe("prefix.with.dots/");
|
||||
});
|
||||
|
||||
test("should handle the cases from the bug report", () => {
|
||||
expect(normalizeS3Path("instance-backups/")).toBe("instance-backups/");
|
||||
expect(normalizeS3Path("/instance-backups/")).toBe("instance-backups/");
|
||||
expect(normalizeS3Path("instance-backups")).toBe("instance-backups/");
|
||||
});
|
||||
});
|
||||
@@ -298,7 +298,11 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Preview deployments enabled");
|
||||
toast.success(
|
||||
checked
|
||||
? "Preview deployments enabled"
|
||||
: "Preview deployments disabled",
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
|
||||
@@ -84,6 +84,7 @@ export const RestoreBackup = ({
|
||||
}: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||
|
||||
const { data: destinations = [] } = api.destination.all.useQuery();
|
||||
|
||||
@@ -99,13 +100,18 @@ export const RestoreBackup = ({
|
||||
const destionationId = form.watch("destinationId");
|
||||
|
||||
const debouncedSetSearch = debounce((value: string) => {
|
||||
setDebouncedSearchTerm(value);
|
||||
}, 150);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
}, 300);
|
||||
debouncedSetSearch(value);
|
||||
};
|
||||
|
||||
const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery(
|
||||
{
|
||||
destinationId: destionationId,
|
||||
search,
|
||||
search: debouncedSearchTerm,
|
||||
serverId: serverId ?? "",
|
||||
},
|
||||
{
|
||||
@@ -284,7 +290,8 @@ export const RestoreBackup = ({
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search backup files..."
|
||||
onValueChange={debouncedSetSearch}
|
||||
value={search}
|
||||
onValueChange={handleSearchChange}
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoading ? (
|
||||
@@ -308,6 +315,8 @@ export const RestoreBackup = ({
|
||||
key={file}
|
||||
onSelect={() => {
|
||||
form.setValue("backupFile", file);
|
||||
setSearch(file);
|
||||
setDebouncedSearchTerm(file);
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full justify-between">
|
||||
|
||||
@@ -36,6 +36,7 @@ const PasswordSchema = z.object({
|
||||
password: z.string().min(8, {
|
||||
message: "Password is required",
|
||||
}),
|
||||
issuer: z.string().optional(),
|
||||
});
|
||||
|
||||
const PinSchema = z.object({
|
||||
@@ -66,6 +67,7 @@ export const Enable2FA = () => {
|
||||
try {
|
||||
const { data: enableData, error } = await authClient.twoFactor.enable({
|
||||
password: formData.password,
|
||||
issuer: formData.issuer,
|
||||
});
|
||||
|
||||
if (!enableData) {
|
||||
@@ -217,6 +219,26 @@ export const Enable2FA = () => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="issuer"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Issuer</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter your issuer"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter your password to enable 2FA
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
|
||||
@@ -56,6 +56,7 @@ const randomImages = [
|
||||
export const ProfileForm = () => {
|
||||
const _utils = api.useUtils();
|
||||
const { data, refetch, isLoading } = api.user.get.useQuery();
|
||||
|
||||
const {
|
||||
mutateAsync,
|
||||
isLoading: isUpdating,
|
||||
@@ -84,12 +85,17 @@ export const ProfileForm = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
email: data?.user?.email || "",
|
||||
password: "",
|
||||
image: data?.user?.image || "",
|
||||
currentPassword: "",
|
||||
});
|
||||
form.reset(
|
||||
{
|
||||
email: data?.user?.email || "",
|
||||
password: form.getValues("password") || "",
|
||||
image: data?.user?.image || "",
|
||||
currentPassword: form.getValues("currentPassword") || "",
|
||||
},
|
||||
{
|
||||
keepValues: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (data.user.email) {
|
||||
generateSHA256Hash(data.user.email).then((hash) => {
|
||||
@@ -97,8 +103,7 @@ export const ProfileForm = () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
form.reset();
|
||||
}, [form, form.reset, data]);
|
||||
}, [form, data]);
|
||||
|
||||
const onSubmit = async (values: Profile) => {
|
||||
await mutateAsync({
|
||||
@@ -110,7 +115,12 @@ export const ProfileForm = () => {
|
||||
.then(async () => {
|
||||
await refetch();
|
||||
toast.success("Profile Updated");
|
||||
form.reset();
|
||||
form.reset({
|
||||
email: values.email,
|
||||
password: "",
|
||||
image: values.image,
|
||||
currentPassword: "",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating the profile");
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { GlobeIcon } from "lucide-react";
|
||||
@@ -33,11 +35,19 @@ import { z } from "zod";
|
||||
|
||||
const addServerDomain = z
|
||||
.object({
|
||||
domain: z.string().min(1, { message: "URL is required" }),
|
||||
domain: z.string(),
|
||||
letsEncryptEmail: z.string(),
|
||||
https: z.boolean().optional(),
|
||||
certificateType: z.enum(["letsencrypt", "none", "custom"]),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.https && !data.certificateType) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["certificateType"],
|
||||
message: "Required",
|
||||
});
|
||||
}
|
||||
if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@@ -61,15 +71,18 @@ export const WebDomain = () => {
|
||||
domain: "",
|
||||
certificateType: "none",
|
||||
letsEncryptEmail: "",
|
||||
https: false,
|
||||
},
|
||||
resolver: zodResolver(addServerDomain),
|
||||
});
|
||||
const https = form.watch("https");
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
domain: data?.user?.host || "",
|
||||
certificateType: data?.user?.certificateType,
|
||||
letsEncryptEmail: data?.user?.letsEncryptEmail || "",
|
||||
https: data?.user?.https || false,
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
@@ -79,6 +92,7 @@ export const WebDomain = () => {
|
||||
host: data.domain,
|
||||
letsEncryptEmail: data.letsEncryptEmail,
|
||||
certificateType: data.certificateType,
|
||||
https: data.https,
|
||||
})
|
||||
.then(async () => {
|
||||
await refetch();
|
||||
@@ -155,44 +169,67 @@ export const WebDomain = () => {
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="certificateType"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>
|
||||
{t("settings.server.domain.form.certificate.label")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"settings.server.domain.form.certificate.placeholder",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={"none"}>
|
||||
{t(
|
||||
"settings.server.domain.form.certificateOptions.none",
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value={"letsencrypt"}>
|
||||
{t(
|
||||
"settings.server.domain.form.certificateOptions.letsencrypt",
|
||||
)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
name="https"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm w-full col-span-2">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>HTTPS</FormLabel>
|
||||
<FormDescription>
|
||||
Automatically provision SSL Certificate.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{https && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="certificateType"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>
|
||||
{t("settings.server.domain.form.certificate.label")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"settings.server.domain.form.certificate.placeholder",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={"none"}>
|
||||
{t(
|
||||
"settings.server.domain.form.certificateOptions.none",
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value={"letsencrypt"}>
|
||||
{t(
|
||||
"settings.server.domain.form.certificateOptions.letsencrypt",
|
||||
)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex w-full justify-end col-span-2">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
|
||||
@@ -120,17 +120,6 @@ export const UserNav = () => {
|
||||
Docker
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{data?.role === "owner" && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
router.push("/dashboard/settings");
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
1
apps/dokploy/drizzle/0084_thin_iron_lad.sql
Normal file
1
apps/dokploy/drizzle/0084_thin_iron_lad.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user_temp" ADD COLUMN "https" boolean DEFAULT false NOT NULL;
|
||||
5369
apps/dokploy/drizzle/meta/0084_snapshot.json
Normal file
5369
apps/dokploy/drizzle/meta/0084_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -589,6 +589,13 @@
|
||||
"when": 1743288371413,
|
||||
"tag": "0083_parallel_stranger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 84,
|
||||
"version": "7",
|
||||
"when": 1743923992280,
|
||||
"tag": "0084_thin_iron_lad",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.21.3",
|
||||
"version": "v0.21.7",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -92,7 +92,7 @@
|
||||
"adm-zip": "^0.5.14",
|
||||
"ai": "^4.0.23",
|
||||
"bcrypt": "5.1.1",
|
||||
"better-auth": "1.2.4",
|
||||
"better-auth": "1.2.6",
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
"bullmq": "5.4.2",
|
||||
|
||||
@@ -314,31 +314,43 @@ const Project = (
|
||||
};
|
||||
|
||||
const applicationActions = {
|
||||
start: api.application.start.useMutation(),
|
||||
stop: api.application.stop.useMutation(),
|
||||
move: api.application.move.useMutation(),
|
||||
delete: api.application.delete.useMutation(),
|
||||
};
|
||||
|
||||
const postgresActions = {
|
||||
start: api.postgres.start.useMutation(),
|
||||
stop: api.postgres.stop.useMutation(),
|
||||
move: api.postgres.move.useMutation(),
|
||||
delete: api.postgres.remove.useMutation(),
|
||||
};
|
||||
|
||||
const mysqlActions = {
|
||||
start: api.mysql.start.useMutation(),
|
||||
stop: api.mysql.stop.useMutation(),
|
||||
move: api.mysql.move.useMutation(),
|
||||
delete: api.mysql.remove.useMutation(),
|
||||
};
|
||||
|
||||
const mariadbActions = {
|
||||
start: api.mariadb.start.useMutation(),
|
||||
stop: api.mariadb.stop.useMutation(),
|
||||
move: api.mariadb.move.useMutation(),
|
||||
delete: api.mariadb.remove.useMutation(),
|
||||
};
|
||||
|
||||
const redisActions = {
|
||||
start: api.redis.start.useMutation(),
|
||||
stop: api.redis.stop.useMutation(),
|
||||
move: api.redis.move.useMutation(),
|
||||
delete: api.redis.remove.useMutation(),
|
||||
};
|
||||
|
||||
const mongoActions = {
|
||||
start: api.mongo.start.useMutation(),
|
||||
stop: api.mongo.stop.useMutation(),
|
||||
move: api.mongo.move.useMutation(),
|
||||
delete: api.mongo.remove.useMutation(),
|
||||
};
|
||||
@@ -348,7 +360,34 @@ const Project = (
|
||||
setIsBulkActionLoading(true);
|
||||
for (const serviceId of selectedServices) {
|
||||
try {
|
||||
await composeActions.start.mutateAsync({ composeId: serviceId });
|
||||
const service = filteredServices.find((s) => s.id === serviceId);
|
||||
if (!service) continue;
|
||||
|
||||
switch (service.type) {
|
||||
case "application":
|
||||
await applicationActions.start.mutateAsync({
|
||||
applicationId: serviceId,
|
||||
});
|
||||
break;
|
||||
case "compose":
|
||||
await composeActions.start.mutateAsync({ composeId: serviceId });
|
||||
break;
|
||||
case "postgres":
|
||||
await postgresActions.start.mutateAsync({ postgresId: serviceId });
|
||||
break;
|
||||
case "mysql":
|
||||
await mysqlActions.start.mutateAsync({ mysqlId: serviceId });
|
||||
break;
|
||||
case "mariadb":
|
||||
await mariadbActions.start.mutateAsync({ mariadbId: serviceId });
|
||||
break;
|
||||
case "redis":
|
||||
await redisActions.start.mutateAsync({ redisId: serviceId });
|
||||
break;
|
||||
case "mongo":
|
||||
await mongoActions.start.mutateAsync({ mongoId: serviceId });
|
||||
break;
|
||||
}
|
||||
success++;
|
||||
} catch (_error) {
|
||||
toast.error(`Error starting service ${serviceId}`);
|
||||
@@ -368,7 +407,34 @@ const Project = (
|
||||
setIsBulkActionLoading(true);
|
||||
for (const serviceId of selectedServices) {
|
||||
try {
|
||||
await composeActions.stop.mutateAsync({ composeId: serviceId });
|
||||
const service = filteredServices.find((s) => s.id === serviceId);
|
||||
if (!service) continue;
|
||||
|
||||
switch (service.type) {
|
||||
case "application":
|
||||
await applicationActions.stop.mutateAsync({
|
||||
applicationId: serviceId,
|
||||
});
|
||||
break;
|
||||
case "compose":
|
||||
await composeActions.stop.mutateAsync({ composeId: serviceId });
|
||||
break;
|
||||
case "postgres":
|
||||
await postgresActions.stop.mutateAsync({ postgresId: serviceId });
|
||||
break;
|
||||
case "mysql":
|
||||
await mysqlActions.stop.mutateAsync({ mysqlId: serviceId });
|
||||
break;
|
||||
case "mariadb":
|
||||
await mariadbActions.stop.mutateAsync({ mariadbId: serviceId });
|
||||
break;
|
||||
case "redis":
|
||||
await redisActions.stop.mutateAsync({ redisId: serviceId });
|
||||
break;
|
||||
case "mongo":
|
||||
await mongoActions.stop.mutateAsync({ mongoId: serviceId });
|
||||
break;
|
||||
}
|
||||
success++;
|
||||
} catch (_error) {
|
||||
toast.error(`Error stopping service ${serviceId}`);
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { DialogFooter } from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@/components/ui/form";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { validateRequest } from "@dokploy/server";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import { Settings } from "lucide-react";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { type ReactElement, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import superjson from "superjson";
|
||||
import { z } from "zod";
|
||||
|
||||
const settings = z.object({
|
||||
cleanCacheOnApplications: z.boolean(),
|
||||
cleanCacheOnCompose: z.boolean(),
|
||||
cleanCacheOnPreviews: z.boolean(),
|
||||
});
|
||||
|
||||
type SettingsType = z.infer<typeof settings>;
|
||||
|
||||
const Page = () => {
|
||||
const { data, refetch } = api.user.get.useQuery();
|
||||
const { mutateAsync, isLoading, isError, error } =
|
||||
api.user.update.useMutation();
|
||||
const form = useForm<SettingsType>({
|
||||
defaultValues: {
|
||||
cleanCacheOnApplications: false,
|
||||
cleanCacheOnCompose: false,
|
||||
cleanCacheOnPreviews: false,
|
||||
},
|
||||
resolver: zodResolver(settings),
|
||||
});
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
cleanCacheOnApplications: data?.user.cleanupCacheApplications || false,
|
||||
cleanCacheOnCompose: data?.user.cleanupCacheOnCompose || false,
|
||||
cleanCacheOnPreviews: data?.user.cleanupCacheOnPreviews || false,
|
||||
});
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
||||
|
||||
const onSubmit = async (values: SettingsType) => {
|
||||
await mutateAsync({
|
||||
cleanupCacheApplications: values.cleanCacheOnApplications,
|
||||
cleanupCacheOnCompose: values.cleanCacheOnCompose,
|
||||
cleanupCacheOnPreviews: values.cleanCacheOnPreviews,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Settings updated");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong");
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Settings className="size-6 text-muted-foreground self-center" />
|
||||
Settings
|
||||
</CardTitle>
|
||||
<CardDescription>Manage your Dokploy settings</CardDescription>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-add-security"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-2"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cleanCacheOnApplications"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Clean Cache on Applications</FormLabel>
|
||||
<FormDescription>
|
||||
Clean the cache after every application deployment
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cleanCacheOnPreviews"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Clean Cache on Previews</FormLabel>
|
||||
<FormDescription>
|
||||
Clean the cache after every preview deployment
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cleanCacheOnCompose"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Clean Cache on Compose</FormLabel>
|
||||
<FormDescription>
|
||||
Clean the cache after every compose deployment
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-add-security"
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout metaName="Server">{page}</DashboardLayout>;
|
||||
};
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||
) {
|
||||
const { req, res } = ctx;
|
||||
const { user, session } = await validateRequest(ctx.req);
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (user.role === "member") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/settings/profile",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: res as any,
|
||||
db: null as any,
|
||||
session: session as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
await helpers.user.get.prefetch();
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
}
|
||||
1
apps/dokploy/public/locales/nl/common.json
Normal file
1
apps/dokploy/public/locales/nl/common.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
58
apps/dokploy/public/locales/nl/settings.json
Normal file
58
apps/dokploy/public/locales/nl/settings.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"settings.common.save": "Opslaan",
|
||||
"settings.common.enterTerminal": "Terminal",
|
||||
"settings.server.domain.title": "Server Domein",
|
||||
"settings.server.domain.description": "Voeg een domein toe aan jouw server applicatie.",
|
||||
"settings.server.domain.form.domain": "Domein",
|
||||
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt Email",
|
||||
"settings.server.domain.form.certificate.label": "Certificaat Aanbieder",
|
||||
"settings.server.domain.form.certificate.placeholder": "Select een certificaat",
|
||||
"settings.server.domain.form.certificateOptions.none": "Geen",
|
||||
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
|
||||
|
||||
"settings.server.webServer.title": "Web Server",
|
||||
"settings.server.webServer.description": "Herlaad of maak de web server schoon.",
|
||||
"settings.server.webServer.actions": "Acties",
|
||||
"settings.server.webServer.reload": "Herladen",
|
||||
"settings.server.webServer.watchLogs": "Bekijk Logs",
|
||||
"settings.server.webServer.updateServerIp": "Update de Server IP",
|
||||
"settings.server.webServer.server.label": "Server",
|
||||
"settings.server.webServer.traefik.label": "Traefik",
|
||||
"settings.server.webServer.traefik.modifyEnv": "Bewerk Omgeving",
|
||||
"settings.server.webServer.traefik.managePorts": "Extra Poort Mappings",
|
||||
"settings.server.webServer.traefik.managePortsDescription": "Bewerk extra Poorten voor Traefik",
|
||||
"settings.server.webServer.traefik.targetPort": "Doel Poort",
|
||||
"settings.server.webServer.traefik.publishedPort": "Gepubliceerde Poort",
|
||||
"settings.server.webServer.traefik.addPort": "Voeg Poort toe",
|
||||
"settings.server.webServer.traefik.portsUpdated": "Poorten succesvol aangepast",
|
||||
"settings.server.webServer.traefik.portsUpdateError": "Poorten niet succesvol aangepast",
|
||||
"settings.server.webServer.traefik.publishMode": "Publiceer Mode",
|
||||
"settings.server.webServer.storage.label": "Opslag",
|
||||
"settings.server.webServer.storage.cleanUnusedImages": "Maak ongebruikte images schoon",
|
||||
"settings.server.webServer.storage.cleanUnusedVolumes": "Maak ongebruikte volumes schoon",
|
||||
"settings.server.webServer.storage.cleanStoppedContainers": "Maak gestopte containers schoon",
|
||||
"settings.server.webServer.storage.cleanDockerBuilder": "Maak Docker Builder & Systeem schoon",
|
||||
"settings.server.webServer.storage.cleanMonitoring": "Maak monitoor schoon",
|
||||
"settings.server.webServer.storage.cleanAll": "Maak alles schoon",
|
||||
|
||||
"settings.profile.title": "Account",
|
||||
"settings.profile.description": "Veramder details van account.",
|
||||
"settings.profile.email": "Email",
|
||||
"settings.profile.password": "Wachtwoord",
|
||||
"settings.profile.avatar": "Profiel Icoon",
|
||||
|
||||
"settings.appearance.title": "Uiterlijk",
|
||||
"settings.appearance.description": "Verander het thema van je dashboard.",
|
||||
"settings.appearance.theme": "Thema",
|
||||
"settings.appearance.themeDescription": "Selecteer een thema voor je dashboard.",
|
||||
"settings.appearance.themes.light": "Licht",
|
||||
"settings.appearance.themes.dark": "Donker",
|
||||
"settings.appearance.themes.system": "Systeem",
|
||||
"settings.appearance.language": "Taal",
|
||||
"settings.appearance.languageDescription": "Selecteer een taal voor je dashboard.",
|
||||
|
||||
"settings.terminal.connectionSettings": "Verbindings instellingen",
|
||||
"settings.terminal.ipAddress": "IP Address",
|
||||
"settings.terminal.port": "Poort",
|
||||
"settings.terminal.username": "Gebruikersnaam"
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
findApplicationById,
|
||||
findProjectById,
|
||||
getApplicationStats,
|
||||
mechanizeDockerContainer,
|
||||
readConfig,
|
||||
readRemoteConfig,
|
||||
removeDeployments,
|
||||
@@ -132,28 +133,36 @@ export const applicationRouter = createTRPCRouter({
|
||||
.input(apiReloadApplication)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const application = await findApplicationById(input.applicationId);
|
||||
if (
|
||||
application.project.organizationId !== ctx.session.activeOrganizationId
|
||||
) {
|
||||
|
||||
try {
|
||||
if (
|
||||
application.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to reload this application",
|
||||
});
|
||||
}
|
||||
|
||||
if (application.serverId) {
|
||||
await stopServiceRemote(application.serverId, input.appName);
|
||||
} else {
|
||||
await stopService(input.appName);
|
||||
}
|
||||
|
||||
await updateApplicationStatus(input.applicationId, "idle");
|
||||
await mechanizeDockerContainer(application);
|
||||
await updateApplicationStatus(input.applicationId, "done");
|
||||
return true;
|
||||
} catch (error) {
|
||||
await updateApplicationStatus(input.applicationId, "error");
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to reload this application",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Error reloading application",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (application.serverId) {
|
||||
await stopServiceRemote(application.serverId, input.appName);
|
||||
} else {
|
||||
await stopService(input.appName);
|
||||
}
|
||||
await updateApplicationStatus(input.applicationId, "idle");
|
||||
|
||||
if (application.serverId) {
|
||||
await startServiceRemote(application.serverId, input.appName);
|
||||
} else {
|
||||
await startService(input.appName);
|
||||
}
|
||||
await updateApplicationStatus(input.applicationId, "done");
|
||||
return true;
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
|
||||
@@ -31,7 +31,10 @@ import {
|
||||
} from "@dokploy/server";
|
||||
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import { getS3Credentials } from "@dokploy/server/utils/backups/utils";
|
||||
import {
|
||||
getS3Credentials,
|
||||
normalizeS3Path,
|
||||
} from "@dokploy/server/utils/backups/utils";
|
||||
import {
|
||||
execAsync,
|
||||
execAsyncRemote,
|
||||
@@ -257,7 +260,7 @@ export const backupRouter = createTRPCRouter({
|
||||
const lastSlashIndex = input.search.lastIndexOf("/");
|
||||
const baseDir =
|
||||
lastSlashIndex !== -1
|
||||
? input.search.slice(0, lastSlashIndex + 1)
|
||||
? normalizeS3Path(input.search.slice(0, lastSlashIndex + 1))
|
||||
: "";
|
||||
const searchTerm =
|
||||
lastSlashIndex !== -1
|
||||
@@ -270,7 +273,7 @@ export const backupRouter = createTRPCRouter({
|
||||
let stdout = "";
|
||||
|
||||
if (input.serverId) {
|
||||
const result = await execAsyncRemote(listCommand, input.serverId);
|
||||
const result = await execAsyncRemote(input.serverId, listCommand);
|
||||
stdout = result.stdout;
|
||||
} else {
|
||||
const result = await execAsync(listCommand);
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
import {
|
||||
IS_CLOUD,
|
||||
createRegistry,
|
||||
execAsync,
|
||||
execAsyncRemote,
|
||||
execFileAsync,
|
||||
findRegistryById,
|
||||
removeRegistry,
|
||||
updateRegistry,
|
||||
@@ -83,7 +83,13 @@ export const registryRouter = createTRPCRouter({
|
||||
.input(apiTestRegistry)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const loginCommand = `echo ${input.password} | docker login ${input.registryUrl} --username ${input.username} --password-stdin`;
|
||||
const args = [
|
||||
"login",
|
||||
input.registryUrl,
|
||||
"--username",
|
||||
input.username,
|
||||
"--password-stdin",
|
||||
];
|
||||
|
||||
if (IS_CLOUD && !input.serverId) {
|
||||
throw new TRPCError({
|
||||
@@ -93,9 +99,14 @@ export const registryRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
if (input.serverId && input.serverId !== "none") {
|
||||
await execAsyncRemote(input.serverId, loginCommand);
|
||||
await execAsyncRemote(
|
||||
input.serverId,
|
||||
`echo ${input.password} | docker ${args.join(" ")}`,
|
||||
);
|
||||
} else {
|
||||
await execAsync(loginCommand);
|
||||
await execFileAsync("docker", args, {
|
||||
input: Buffer.from(input.password).toString(),
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -184,6 +184,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
letsEncryptEmail: input.letsEncryptEmail,
|
||||
}),
|
||||
certificateType: input.certificateType,
|
||||
https: input.https,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { applyWSSHandler } from "@trpc/server/adapters/ws";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { appRouter } from "../api/root";
|
||||
import { createTRPCContext } from "../api/trpc";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
|
||||
export const setupDrawerLogsWebSocketServer = (
|
||||
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
||||
@@ -32,8 +33,13 @@ export const setupDrawerLogsWebSocketServer = (
|
||||
}
|
||||
});
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
wssTerm.close();
|
||||
};
|
||||
wssTerm.on("connection", async (ws, req) => {
|
||||
const _url = new URL(req.url || "", `http://${req.headers.host}`);
|
||||
const { user, session } = await validateRequest(req);
|
||||
|
||||
if (!user || !session) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -27,28 +27,15 @@ const getWsUrl = () => {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const host = window.location.host;
|
||||
|
||||
// Use the base URL for all tRPC WebSocket connections
|
||||
return `${protocol}${host}/drawer-logs`;
|
||||
};
|
||||
|
||||
// Singleton WebSocket client instance
|
||||
let wsClientInstance: ReturnType<typeof createWSClient> | null = null;
|
||||
|
||||
const getWsClient = () => {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
if (!wsClientInstance) {
|
||||
wsClientInstance = createWSClient({
|
||||
url: getWsUrl() || "",
|
||||
onClose: () => {
|
||||
// Reset the instance when connection closes so it can be recreated
|
||||
wsClientInstance = null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return wsClientInstance;
|
||||
};
|
||||
const wsClient =
|
||||
typeof window !== "undefined"
|
||||
? createWSClient({
|
||||
url: getWsUrl() || "",
|
||||
})
|
||||
: null;
|
||||
|
||||
/** A set of type-safe react-query hooks for your tRPC API. */
|
||||
export const api = createTRPCNext<AppRouter>({
|
||||
@@ -70,7 +57,7 @@ export const api = createTRPCNext<AppRouter>({
|
||||
splitLink({
|
||||
condition: (op) => op.type === "subscription",
|
||||
true: wsLink({
|
||||
client: getWsClient()!,
|
||||
client: wsClient!,
|
||||
}),
|
||||
false: splitLink({
|
||||
condition: (op) => op.input instanceof FormData,
|
||||
|
||||
Reference in New Issue
Block a user