Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions apps/dokploy/__test__/utils/remote-stream.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";

vi.mock("@dokploy/server/services/server", () => ({
findServerById: vi.fn(),
}));

import { pipeBetweenServers } from "@dokploy/server/utils/process/remoteStream";

describe("pipeBetweenServers", () => {
it("delivers a short source stream that ends before the target is ready", async () => {
const bytes = await pipeBetweenServers({
source: { serverId: null, command: "printf 'hello world'" },
target: { serverId: null, command: "sleep 0.3; cat > /dev/null" },
});
expect(bytes).toBe(11);
});

it("pipes data larger than the pipe buffer unchanged", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-"));
const source = path.join(dir, "src.bin");
const target = path.join(dir, "dst.bin");
const size = 1024 * 1024;
const progress: number[] = [];

const bytes = await pipeBetweenServers({
source: {
serverId: null,
command: `head -c ${size} /dev/urandom | tee '${source}'`,
},
target: { serverId: null, command: `cat > '${target}'` },
onProgress: (transferred) => progress.push(transferred),
});

expect(bytes).toBe(size);
expect(progress.at(-1)).toBe(size);
expect(await fs.readFile(target)).toEqual(await fs.readFile(source));
await fs.rm(dir, { recursive: true, force: true });
}, 30_000);

it("reports a target failure with its stderr", async () => {
await expect(
pipeBetweenServers({
source: { serverId: null, command: "printf x" },
target: { serverId: null, command: "echo boom >&2; exit 3" },
}),
).rejects.toThrow("target exited with code 3: boom");
});

it("reports a source failure", async () => {
await expect(
pipeBetweenServers({
source: { serverId: null, command: "exit 2" },
target: { serverId: null, command: "cat > /dev/null" },
}),
).rejects.toThrow("source exited with code 2");
});
});
245 changes: 245 additions & 0 deletions apps/dokploy/components/dashboard/shared/transfer-service.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import type { ServiceType } from "@dokploy/server/db/schema";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { ArrowRightLeft } from "lucide-react";
import { 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 { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { type LogLine, parseLogs } from "../docker/logs/utils";

const LOCAL_SERVER = "dokploy";

const transferSchema = z.object({
targetServerId: z.string().min(1, { message: "Select a target server" }),
removeSourceData: z.boolean(),
});

type TransferForm = z.infer<typeof transferSchema>;

interface Props {
id: string;
type: ServiceType;
serverId?: string | null;
}

export const TransferService = ({ id, type, serverId }: Props) => {
const utils = api.useUtils();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
const [isOpen, setIsOpen] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [isTransferring, setIsTransferring] = useState(false);
const [logs, setLogs] = useState<LogLine[]>([]);
const [request, setRequest] = useState<{
targetServerId: string | null;
removeSourceData: boolean;
} | null>(null);

const targets = [
...(!isCloud && serverId
? [{ serverId: LOCAL_SERVER, name: "Dokploy Server" }]
: []),
...(servers ?? []).filter((server) => server.serverId !== serverId),
];

const form = useForm<TransferForm>({
defaultValues: { targetServerId: "", removeSourceData: false },
resolver: zodResolver(transferSchema),
});

api.transfer.start.useSubscription(
{
serviceType: type,
serviceId: id,
targetServerId: request?.targetServerId ?? null,
removeSourceData: request?.removeSourceData ?? false,
},
{
enabled: isTransferring && request !== null,
onData(line) {
setLogs((prev) => [...prev, ...parseLogs(line)]);
if (line.startsWith("Transfer completed")) {
setIsTransferring(false);
toast.success("Service transferred successfully");
utils.invalidate();
} else if (line.startsWith("Transfer failed")) {
setIsTransferring(false);
toast.error("Transfer failed, check the logs");
utils.invalidate();
}
},
onError(error) {
setIsTransferring(false);
toast.error(error.message);
},
},
);

const onSubmit = (values: TransferForm) => {
setRequest({
targetServerId:
values.targetServerId === LOCAL_SERVER ? null : values.targetServerId,
removeSourceData: values.removeSourceData,
});
setLogs([]);
setIsDrawerOpen(true);
setIsTransferring(true);
setIsOpen(false);
};

return (
<>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
isLoading={isTransferring}
>
<ArrowRightLeft className="size-4 text-primary group-hover:text-blue-500" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Transfer to another server</DialogTitle>
<DialogDescription>
Moves this service with its volumes, bind mounts, file mounts and
configuration to the selected server, then deploys it there.
</DialogDescription>
</DialogHeader>
{targets.length === 0 ? (
<AlertBlock type="info">
There are no other servers available to transfer this service to.
</AlertBlock>
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-transfer-service"
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="targetServerId"
render={({ field }) => (
<FormItem>
<FormLabel>Target server</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a server" />
</SelectTrigger>
</FormControl>
<SelectContent>
{targets.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
{server.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="removeSourceData"
render={({ field }) => (
<FormItem>
<div className="flex items-center">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="ml-2">
Remove volumes from the source server after the
transfer
</FormLabel>
</div>
<FormMessage />
</FormItem>
)}
/>
<AlertBlock type="warning">
<ul className="list-disc pl-4 space-y-1">
<li>The service is stopped while its data is copied.</li>
<li>
Point your DNS records to the new server, certificates are
issued again there.
</li>
<li>
Networks that only exist on the current server are
detached.
</li>
<li>
Bind mount host paths are copied but never deleted from
the source server.
</li>
</ul>
</AlertBlock>
</form>
</Form>
)}
<DialogFooter>
<Button variant="secondary" onClick={() => setIsOpen(false)}>
Cancel
</Button>
{targets.length > 0 && (
<Button
isLoading={isTransferring}
form="hook-form-transfer-service"
type="submit"
>
Transfer
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<DrawerLogs
isOpen={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
filteredLogs={logs}
/>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
import { AssignNetworks } from "@/components/dashboard/networks/assign-networks";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { StatusTooltip } from "@/components/shared/status-tooltip";
Expand Down Expand Up @@ -194,6 +195,13 @@ const Service = (
{permissions?.service.create && (
<UpdateApplication applicationId={applicationId} />
)}
{permissions?.service.create && (
<TransferService
id={applicationId}
type="application"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<DeleteService id={applicationId} type="application" />
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { ShowBackups } from "@/components/dashboard/database/backups/show-backup
import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring";
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
import { AssignComposeNetworks } from "@/components/dashboard/networks/assign-compose-networks";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { StatusTooltip } from "@/components/shared/status-tooltip";
Expand Down Expand Up @@ -184,6 +185,13 @@ const Service = (
<UpdateCompose composeId={composeId} />
)}

{permissions?.service.create && (
<TransferService
id={composeId}
type="compose"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<DeleteService id={composeId} type="compose" />
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { UpdateLibsql } from "@/components/dashboard/libsql/update-libsql";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { LibsqlIcon } from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
Expand Down Expand Up @@ -145,6 +146,13 @@ const Libsql = (
</div>
<div className="flex flex-row gap-2 justify-end">
<UpdateLibsql libsqlId={libsqlId} />
{(auth?.role === "owner" || auth?.canCreateServices) && (
<TransferService
id={libsqlId}
type="libsql"
serverId={data?.serverId}
/>
)}
{(auth?.role === "owner" || auth?.canDeleteServices) && (
<DeleteService id={libsqlId} type="libsql" />
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { UpdateMariadb } from "@/components/dashboard/mariadb/update-mariadb";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
import { ShowDatabaseAdvancedSettings } from "@/components/dashboard/shared/show-database-advanced-settings";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { MariadbIcon } from "@/components/icons/data-tools-icons";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
Expand Down Expand Up @@ -159,6 +160,13 @@ const Mariadb = (
{permissions?.service.create && (
<UpdateMariadb mariadbId={mariadbId} />
)}
{permissions?.service.create && (
<TransferService
id={mariadbId}
type="mariadb"
serverId={data?.serverId}
/>
)}
{permissions?.service.delete && (
<DeleteService id={mariadbId} type="mariadb" />
)}
Expand Down
Loading