Created
diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
index 6e56bdd708..2f04620f39 100644
--- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
+++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx
@@ -91,7 +91,10 @@ export const ShowBilling = () => {
api.stripe.upgradeSubscription.useMutation();
const utils = api.useUtils();
- const [serverQuantity, setServerQuantity] = useState(3);
+ const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
+ const [startupServerQuantity, setStartupServerQuantity] = useState(
+ STARTUP_SERVERS_INCLUDED,
+ );
const [isAnnual, setIsAnnual] = useState(false);
const [upgradeTier, setUpgradeTier] = useState<"hobby" | "startup" | null>(
null,
@@ -111,6 +114,12 @@ export const ShowBilling = () => {
productId: string,
) => {
const stripe = await stripePromise;
+ const serverQuantity =
+ tier === "startup"
+ ? startupServerQuantity
+ : tier === "hobby"
+ ? hobbyServerQuantity
+ : hobbyServerQuantity;
if (data && data.subscriptions.length === 0) {
createCheckoutSession({
tier,
@@ -679,7 +688,7 @@ export const ShowBilling = () => {
$
{calculatePriceHobby(
- serverQuantity,
+ hobbyServerQuantity,
isAnnual,
).toFixed(2)}
/{isAnnual ? "yr" : "mo"}
@@ -692,7 +701,8 @@ export const ShowBilling = () => {
$
{(
- calculatePriceHobby(serverQuantity, true) / 12
+ calculatePriceHobby(hobbyServerQuantity, true) /
+ 12
).toFixed(2)}
/mo
@@ -724,19 +734,19 @@ export const ShowBilling = () => {
Servers:
- setServerQuantity(
+ setHobbyServerQuantity(
Math.max(
1,
Number(
@@ -750,7 +760,7 @@ export const ShowBilling = () => {
@@ -775,7 +785,7 @@ export const ShowBilling = () => {
onClick={() =>
handleCheckout("hobby", data!.hobbyProductId!)
}
- disabled={serverQuantity < 1}
+ disabled={hobbyServerQuantity < 1}
>
Get Started
@@ -806,7 +816,7 @@ export const ShowBilling = () => {
$
{calculatePriceStartup(
- serverQuantity,
+ startupServerQuantity,
isAnnual,
).toFixed(2)}
/{isAnnual ? "yr" : "mo"}
@@ -819,7 +829,10 @@ export const ShowBilling = () => {
$
{(
- calculatePriceStartup(serverQuantity, true) / 12
+ calculatePriceStartup(
+ startupServerQuantity,
+ true,
+ ) / 12
).toFixed(2)}
/mo
@@ -856,13 +869,14 @@ export const ShowBilling = () => {
- setServerQuantity(
+ setStartupServerQuantity(
Math.max(
STARTUP_SERVERS_INCLUDED,
Number(
@@ -887,7 +901,9 @@ export const ShowBilling = () => {
variant="outline"
size="icon"
className="h-8 w-8"
- onClick={() => setServerQuantity((q) => q + 1)}
+ onClick={() =>
+ setStartupServerQuantity((q) => q + 1)
+ }
>
@@ -917,7 +933,7 @@ export const ShowBilling = () => {
)
}
disabled={
- serverQuantity < STARTUP_SERVERS_INCLUDED
+ startupServerQuantity < STARTUP_SERVERS_INCLUDED
}
>
Get Started
@@ -1009,7 +1025,7 @@ export const ShowBilling = () => {
${" "}
{calculatePrice(
- serverQuantity,
+ hobbyServerQuantity,
isAnnual,
).toFixed(2)}{" "}
USD
@@ -1018,7 +1034,10 @@ export const ShowBilling = () => {
${" "}
{(
- calculatePrice(serverQuantity, isAnnual) / 12
+ calculatePrice(
+ hobbyServerQuantity,
+ isAnnual,
+ ) / 12
).toFixed(2)}{" "}
/ Month USD
@@ -1026,9 +1045,10 @@ export const ShowBilling = () => {
) : (
${" "}
- {calculatePrice(serverQuantity, isAnnual).toFixed(
- 2,
- )}{" "}
+ {calculatePrice(
+ hobbyServerQuantity,
+ isAnnual,
+ ).toFixed(2)}{" "}
USD
)}
@@ -1071,26 +1091,28 @@ export const ShowBilling = () => {
- {serverQuantity} Servers
+ {hobbyServerQuantity} Servers
{
- setServerQuantity(
+ setHobbyServerQuantity(
e.target.value as unknown as number,
);
}}
@@ -1099,7 +1121,9 @@ export const ShowBilling = () => {
diff --git a/apps/dokploy/components/dashboard/settings/certificates/utils.ts b/apps/dokploy/components/dashboard/settings/certificates/utils.ts
index e2aa59ef3a..79b763a970 100644
--- a/apps/dokploy/components/dashboard/settings/certificates/utils.ts
+++ b/apps/dokploy/components/dashboard/settings/certificates/utils.ts
@@ -14,13 +14,13 @@ export const extractExpirationDate = (certData: string): Date | null => {
// Helper: read ASN.1 length field
function readLength(pos: number): { length: number; offset: number } {
- // biome-ignore lint/style/noParameterAssign:
+ // biome-ignore lint/style/noParameterAssign: this is for dynamic length calculation
let len = der[pos++];
if (len & 0x80) {
const bytes = len & 0x7f;
len = 0;
for (let i = 0; i < bytes; i++) {
- // biome-ignore lint/style/noParameterAssign:
+ // biome-ignore lint/style/noParameterAssign: this is for dynamic length calculation
len = (len << 8) + der[pos++];
}
}
diff --git a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx
index 966c8e5f5b..4ba9072ec0 100644
--- a/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx
+++ b/apps/dokploy/components/dashboard/settings/destination/handle-destinations.tsx
@@ -1,443 +1,213 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form";
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
-import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectLabel,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
+import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { S3_PROVIDERS } from "./constants";
-const addDestination = z.object({
+const baseSchema = z.object({
name: z.string().min(1, "Name is required"),
- provider: z.string().min(1, "Provider is required"),
- accessKeyId: z.string().min(1, "Access Key Id is required"),
- secretAccessKey: z.string().min(1, "Secret Access Key is required"),
- bucket: z.string().min(1, "Bucket is required"),
- region: z.string(),
- endpoint: z.string().min(1, "Endpoint is required"),
- serverId: z.string().optional(),
+ destinationType: z.enum(["s3", "sftp", "ftp", "gdrive"]),
+ provider: z.string().optional(), accessKeyId: z.string().optional(), secretAccessKey: z.string().optional(), bucket: z.string().optional(), region: z.string().optional(), endpoint: z.string().optional(),
+ host: z.string().optional(), port: z.string().optional(), username: z.string().optional(), password: z.string().optional(), remotePath: z.string().optional(), serviceAccountJson: z.string().optional(), serverId: z.string().optional(),
});
-
+const addDestination = z.discriminatedUnion("destinationType", [
+ baseSchema.extend({ destinationType: z.literal("s3"), provider: z.string().min(1), accessKeyId: z.string().min(1), secretAccessKey: z.string().min(1), bucket: z.string().min(1), endpoint: z.string().min(1), region: z.string().optional() }),
+ baseSchema.extend({ destinationType: z.literal("sftp"), host: z.string().min(1), port: z.string().min(1), username: z.string().min(1), password: z.string().min(1), remotePath: z.string().min(1) }),
+ baseSchema.extend({ destinationType: z.literal("ftp"), host: z.string().min(1), port: z.string().min(1), username: z.string().min(1), password: z.string().min(1), remotePath: z.string().min(1) }),
+ baseSchema.extend({ destinationType: z.literal("gdrive"), serviceAccountJson: z.string().min(1), remotePath: z.string().min(1) }),
+]);
type AddDestination = z.infer;
-interface Props {
- destinationId?: string;
-}
+interface Props { destinationId?: string; }
+const DESTINATION_TYPES = [
+ { value: "s3", label: "S3" },
+ { value: "sftp", label: "SFTP" },
+ { value: "ftp", label: "FTP" },
+ { value: "gdrive", label: "Google Drive" },
+] as const;
export const HandleDestinations = ({ destinationId }: Props) => {
const [open, setOpen] = useState(false);
const utils = api.useUtils();
const { data: servers } = api.server.withSSHKey.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
-
- const { mutateAsync, isError, error, isPending } = destinationId
- ? api.destination.update.useMutation()
- : api.destination.create.useMutation();
-
- const { data: destination } = api.destination.one.useQuery(
- {
- destinationId: destinationId || "",
- },
- {
- enabled: !!destinationId,
- refetchOnWindowFocus: false,
- },
- );
- const {
- mutateAsync: testConnection,
- isPending: isPendingConnection,
- error: connectionError,
- isError: isErrorConnection,
- } = api.destination.testConnection.useMutation();
+ const { mutateAsync, isError, error, isPending } = destinationId ? api.destination.update.useMutation() : api.destination.create.useMutation();
+ const { data: destination } = api.destination.one.useQuery({ destinationId: destinationId || "" }, { enabled: !!destinationId, refetchOnWindowFocus: false });
+ const { mutateAsync: testConnection, isPending: isPendingConnection, error: connectionError, isError: isErrorConnection } = api.destination.testConnection.useMutation();
const form = useForm({
+ resolver: zodResolver(addDestination),
defaultValues: {
- provider: "",
- accessKeyId: "",
- bucket: "",
- name: "",
- region: "",
- secretAccessKey: "",
- endpoint: "",
+ name: "", destinationType: "s3", provider: "", accessKeyId: "", secretAccessKey: "", bucket: "", region: "", endpoint: "", host: "", port: "22", username: "", password: "", remotePath: "", serviceAccountJson: "", serverId: undefined,
},
- resolver: zodResolver(addDestination),
});
+ const destinationType = form.watch("destinationType");
+
useEffect(() => {
- if (destination) {
- form.reset({
- name: destination.name,
- provider: destination.provider || "",
- accessKeyId: destination.accessKey,
- secretAccessKey: destination.secretAccessKey,
- bucket: destination.bucket,
- region: destination.region,
- endpoint: destination.endpoint,
- });
- } else {
- form.reset();
- }
- }, [form, form.reset, form.formState.isSubmitSuccessful, destination]);
+ if (!destination) return void form.reset();
+ form.reset({
+ name: destination.name,
+ destinationType: destination.destinationType,
+ provider: destination.provider || "",
+ accessKeyId: destination.accessKey || "",
+ secretAccessKey: destination.secretAccessKey || "",
+ bucket: destination.bucket,
+ region: destination.region || "",
+ endpoint: destination.endpoint || "",
+ host: destination.host || "",
+ port: destination.port || (destination.destinationType === "ftp" ? "21" : "22"),
+ username: destination.username || "",
+ password: destination.password || "",
+ remotePath: destination.remoteBasePath || destination.bucket || "",
+ serviceAccountJson: destination.destinationType === "gdrive" ? destination.accessKey || "" : "",
+ });
+ }, [destination, form]);
+
+ const connectionHint = useMemo(() => {
+ if (destinationType === "s3") return "rclone ls :s3:";
+ if (destinationType === "sftp") return "rclone ls :sftp:";
+ if (destinationType === "ftp") return "rclone ls :ftp:";
+ return "rclone ls :drive:";
+ }, [destinationType]);
+
+ const toPayload = (data: AddDestination, serverId?: string) => {
+ if (data.destinationType === "s3") return { name: data.name, destinationType: "s3", provider: data.provider, accessKey: data.accessKeyId, secretAccessKey: data.secretAccessKey, bucket: data.bucket, region: data.region || "", endpoint: data.endpoint, host: "", port: "", username: "", password: "", remoteBasePath: "", serverId };
+ if (data.destinationType === "gdrive") return { name: data.name, destinationType: "gdrive", provider: "", accessKey: data.serviceAccountJson, secretAccessKey: "", bucket: data.remotePath, region: "", endpoint: "", host: "", port: "", username: "", password: "", remoteBasePath: data.remotePath, serverId };
+ const defaultPort = data.destinationType === "ftp" ? "21" : "22";
+ return { name: data.name, destinationType: data.destinationType, provider: "", accessKey: "", secretAccessKey: "", bucket: data.remotePath, region: "", endpoint: "", host: data.host, port: data.port || defaultPort, username: data.username, password: data.password, remoteBasePath: data.remotePath, serverId };
+ };
const onSubmit = async (data: AddDestination) => {
- await mutateAsync({
- provider: data.provider || "",
- accessKey: data.accessKeyId,
- bucket: data.bucket,
- endpoint: data.endpoint,
- name: data.name,
- region: data.region,
- secretAccessKey: data.secretAccessKey,
- destinationId: destinationId || "",
- })
+ const payload = { ...toPayload(data, data.serverId), destinationId: destinationId || "" };
+ await mutateAsync(payload as never)
.then(async () => {
toast.success(`Destination ${destinationId ? "Updated" : "Created"}`);
await utils.destination.all.invalidate();
- if (destinationId) {
- await utils.destination.one.invalidate({ destinationId });
- }
+ if (destinationId) await utils.destination.one.invalidate({ destinationId });
setOpen(false);
})
- .catch(() => {
- toast.error(
- `Error ${destinationId ? "Updating" : "Creating"} the Destination`,
- );
- });
+ .catch(() => toast.error(`Error ${destinationId ? "Updating" : "Creating"} the Destination`));
};
const handleTestConnection = async (serverId?: string) => {
- const result = await form.trigger([
- "provider",
- "accessKeyId",
- "secretAccessKey",
- "bucket",
- "endpoint",
- ]);
-
- if (!result) {
- const errors = form.formState.errors;
- const errorFields = Object.entries(errors)
- .map(([field, error]) => `${field}: ${error?.message}`)
- .filter(Boolean)
- .join("\n");
-
- toast.error("Please fill all required fields", {
- description: errorFields,
- });
- return;
+ const requiredByType = destinationType === "s3" ? ["provider", "accessKeyId", "secretAccessKey", "bucket", "endpoint"] : destinationType === "gdrive" ? ["serviceAccountJson", "remotePath"] : ["host", "port", "username", "password", "remotePath"];
+ const valid = await form.trigger(["name", ...(requiredByType as Array)]);
+ if (!valid) {
+ const errorFields = Object.entries(form.formState.errors).map(([field, fieldError]) => `${field}: ${fieldError?.message}`).filter(Boolean).join("\n");
+ return void toast.error("Please fill all required fields", { description: errorFields });
}
-
- if (isCloud && !serverId) {
- toast.error("Please select a server");
- return;
- }
-
- const provider = form.getValues("provider");
- const accessKey = form.getValues("accessKeyId");
- const secretKey = form.getValues("secretAccessKey");
- const bucket = form.getValues("bucket");
- const endpoint = form.getValues("endpoint");
- const region = form.getValues("region");
-
- const connectionString = `:s3,provider=${provider},access_key_id=${accessKey},secret_access_key=${secretKey},endpoint=${endpoint}${region ? `,region=${region}` : ""}:${bucket}`;
-
- await testConnection({
- provider,
- accessKey,
- bucket,
- endpoint,
- name: "Test",
- region,
- secretAccessKey: secretKey,
- serverId,
- })
- .then(() => {
- toast.success("Connection Success");
- })
- .catch((e) => {
- toast.error("Error connecting to provider", {
- description: `${e.message}\n\nTry manually: rclone ls ${connectionString}`,
- });
- });
+ if (isCloud && !serverId) return void toast.error("Please select a server");
+ await testConnection(toPayload(form.getValues(), serverId) as never)
+ .then(() => toast.success("Connection Success"))
+ .catch((e) => toast.error("Error connecting to destination", { description: `${e.message}\n\nTry manually: ${connectionHint}` }));
};
+ const renderInput = (name: keyof AddDestination, label: string, placeholder: string, type = "text") => (
+ (
+
+ {label}
+
+
+
+ )} />
+ );
+
return (