feat: implement service transfer functionality

Add a new TransferService component to facilitate the transfer of various services (applications, databases, etc.) between servers. This includes updates to the ShowDatabaseAdvancedSettings component to support serverId, and integration of transfer functionality in the application, compose, mariadb, mongo, mysql, postgres, and redis routers. The transfer process includes scanning for transfer readiness and executing the transfer with appropriate permissions and error handling.
This commit is contained in:
Mauricio Siu
2026-04-13 22:36:22 -06:00
parent ddff8b9de7
commit fcbd226796
30 changed files with 2427 additions and 1 deletions

View File

@@ -3,13 +3,15 @@ import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes
import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command";
import { ShowClusterSettings } from "../application/advanced/cluster/show-cluster-settings";
import { RebuildDatabase } from "./rebuild-database";
import { TransferService } from "./transfer-service";
interface Props {
id: string;
type: "libsql" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
serverId?: string | null;
}
export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
export const ShowDatabaseAdvancedSettings = ({ id, type, serverId }: Props) => {
return (
<div className="flex w-full flex-col gap-5">
<ShowCustomCommand id={id} type={type} />
@@ -23,6 +25,13 @@ export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
<ShowVolumes id={id} type={type} />
<ShowResources id={id} type={type} />
<RebuildDatabase id={id} type={type} />
{type !== "libsql" && (
<TransferService
serviceId={id}
serviceType={type}
currentServerId={serverId ?? null}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,493 @@
import { AlertTriangle, ArrowRightLeft, Loader2, Server } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
type ServiceType =
| "application"
| "compose"
| "postgres"
| "mysql"
| "mariadb"
| "mongo"
| "redis";
interface TransferServiceProps {
serviceId: string;
serviceType: ServiceType;
currentServerId: string | null;
}
interface ScanResult {
serviceDirectory: {
files: Array<{
path: string;
status: string;
sourceFile: { path: string; size: number; modifiedAt: number };
targetFile?: { path: string; size: number; modifiedAt: number };
}>;
totalSize: number;
};
traefikConfig: {
exists: boolean;
hasConflict: boolean;
};
mounts: Array<{
mount: {
mountId: string;
type: string;
volumeName?: string | null;
hostPath?: string | null;
mountPath: string;
};
files: Array<{
path: string;
status: string;
}>;
totalSize: number;
}>;
totalTransferSize: number;
totalFiles: number;
conflicts: Array<{
path: string;
status: string;
}>;
}
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
};
const useTransferMutations = (serviceType: ServiceType) => {
const appScan = api.application.transferScan.useMutation();
const appTransfer = api.application.transfer.useMutation();
const composeScan = api.compose.transferScan.useMutation();
const composeTransfer = api.compose.transfer.useMutation();
const postgresScan = api.postgres.transferScan.useMutation();
const postgresTransfer = api.postgres.transfer.useMutation();
const mysqlScan = api.mysql.transferScan.useMutation();
const mysqlTransfer = api.mysql.transfer.useMutation();
const mariadbScan = api.mariadb.transferScan.useMutation();
const mariadbTransfer = api.mariadb.transfer.useMutation();
const mongoScan = api.mongo.transferScan.useMutation();
const mongoTransfer = api.mongo.transfer.useMutation();
const redisScan = api.redis.transferScan.useMutation();
const redisTransfer = api.redis.transfer.useMutation();
const mutations: Record<
ServiceType,
{
scan: { mutateAsync: (input: any) => Promise<any>; isPending: boolean };
transfer: {
mutateAsync: (input: any) => Promise<any>;
isPending: boolean;
};
}
> = {
application: { scan: appScan, transfer: appTransfer },
compose: { scan: composeScan, transfer: composeTransfer },
postgres: { scan: postgresScan, transfer: postgresTransfer },
mysql: { scan: mysqlScan, transfer: mysqlTransfer },
mariadb: { scan: mariadbScan, transfer: mariadbTransfer },
mongo: { scan: mongoScan, transfer: mongoTransfer },
redis: { scan: redisScan, transfer: redisTransfer },
};
return mutations[serviceType];
};
const getServiceIdKey = (serviceType: ServiceType): string => {
const map: Record<ServiceType, string> = {
application: "applicationId",
compose: "composeId",
postgres: "postgresId",
mysql: "mysqlId",
mariadb: "mariadbId",
mongo: "mongoId",
redis: "redisId",
};
return map[serviceType];
};
export const TransferService = ({
serviceId,
serviceType,
currentServerId,
}: TransferServiceProps) => {
const [targetServerId, setTargetServerId] = useState<string>("");
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [step, setStep] = useState<"select" | "scan" | "confirm" | "transfer">(
"select",
);
const [showConfirm, setShowConfirm] = useState(false);
const [transferLogs, setTransferLogs] = useState<string[]>([]);
const { data: servers } = api.server.all.useQuery();
const utils = api.useUtils();
const { scan, transfer } = useTransferMutations(serviceType);
const idKey = getServiceIdKey(serviceType);
const availableServers = servers?.filter(
(s) => s.serverId !== currentServerId,
);
const selectedServer = servers?.find((s) => s.serverId === targetServerId);
const handleScan = async () => {
if (!targetServerId) {
toast.error("Please select a target server");
return;
}
setStep("scan");
try {
const result = await scan.mutateAsync({
[idKey]: serviceId,
targetServerId,
});
setScanResult(result as ScanResult);
setStep("confirm");
} catch (error) {
toast.error(
`Scan failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
setStep("select");
}
};
const handleTransfer = async () => {
setShowConfirm(false);
setStep("transfer");
setTransferLogs([]);
try {
await transfer.mutateAsync({
[idKey]: serviceId,
targetServerId,
decisions: {},
});
toast.success("Transfer completed successfully!");
setTransferLogs((prev) => [...prev, "Transfer completed successfully!"]);
await utils.invalidate();
setTimeout(() => {
setStep("select");
setScanResult(null);
setTargetServerId("");
}, 3000);
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error";
toast.error(`Transfer failed: ${message}`);
setTransferLogs((prev) => [...prev, `Transfer failed: ${message}`]);
setStep("confirm");
}
};
const isDbService = ["postgres", "mysql", "mariadb", "mongo", "redis"].includes(
serviceType,
);
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<ArrowRightLeft className="size-5" />
Transfer Service
</CardTitle>
<CardDescription>
Transfer this {serviceType} service to a different server. Source data
is never modified or deleted.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!availableServers?.length ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Server className="size-4" />
<span>
No other servers available. Add a remote server first.
</span>
</div>
) : (
<>
{/* Step 1: Select target server */}
<div className="space-y-2">
<label className="text-sm font-medium">Target Server</label>
<Select
value={targetServerId}
onValueChange={(value) => {
setTargetServerId(value);
setScanResult(null);
setStep("select");
}}
disabled={step === "transfer"}
>
<SelectTrigger>
<SelectValue placeholder="Select target server" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{availableServers.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
<span className="flex items-center gap-2">
<span>{server.name}</span>
<span className="text-muted-foreground text-xs">
{server.ipAddress}
</span>
</span>
</SelectItem>
))}
<SelectLabel>
Servers ({availableServers.length})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</div>
{/* Scan button */}
{step === "select" && targetServerId && (
<Button
onClick={handleScan}
disabled={scan.isPending}
variant="outline"
>
{scan.isPending ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Scanning...
</>
) : (
"Scan for Transfer"
)}
</Button>
)}
{/* Step 2: Scan in progress */}
{step === "scan" && (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>
Scanning source and target servers for files and
conflicts...
</span>
</div>
)}
{/* Step 3: Scan results + confirm */}
{step === "confirm" && scanResult && (
<div className="space-y-4">
<div className="rounded-lg border p-4 space-y-3">
<h4 className="font-medium">Scan Results</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">
Total Files:
</span>{" "}
<span className="font-medium">
{scanResult.totalFiles}
</span>
</div>
<div>
<span className="text-muted-foreground">
Transfer Size:
</span>{" "}
<span className="font-medium">
{formatBytes(scanResult.totalTransferSize)}
</span>
</div>
<div>
<span className="text-muted-foreground">
Volumes/Mounts:
</span>{" "}
<span className="font-medium">
{scanResult.mounts.length}
</span>
</div>
<div>
<span className="text-muted-foreground">
Conflicts:
</span>{" "}
<Badge
variant={
scanResult.conflicts.length > 0
? "destructive"
: "secondary"
}
>
{scanResult.conflicts.length}
</Badge>
</div>
</div>
{scanResult.traefikConfig.exists && (
<div className="text-sm">
<span className="text-muted-foreground">
Traefik Config:
</span>{" "}
<Badge variant="outline">Will be synced</Badge>
</div>
)}
</div>
{/* Conflict list */}
{scanResult.conflicts.length > 0 && (
<div className="rounded-lg border p-4 space-y-2">
<h4 className="font-medium text-sm">
File Conflicts (will be overwritten)
</h4>
<div className="max-h-40 overflow-y-auto space-y-1">
{scanResult.conflicts.map((conflict) => (
<div
key={conflict.path}
className="text-xs font-mono flex items-center gap-2"
>
<Badge variant="outline" className="text-[10px]">
{conflict.status}
</Badge>
<span className="truncate">
{conflict.path}
</span>
</div>
))}
</div>
</div>
)}
{/* Warning */}
<div className="rounded-lg border border-yellow-500/50 bg-yellow-500/10 p-4 space-y-2">
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
<AlertTriangle className="size-4" />
<span className="font-medium text-sm">
Service Downtime Warning
</span>
</div>
<p className="text-sm text-muted-foreground">
{isDbService
? "Stop the database service before transferring to avoid data corruption. The service will be unavailable until deployed on the target server."
: "The service will be unavailable during transfer. After transfer completes, deploy the service on the target server to start it."}
</p>
</div>
{/* Transfer button */}
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
setStep("select");
setScanResult(null);
}}
>
Cancel
</Button>
<Button
onClick={() => setShowConfirm(true)}
disabled={transfer.isPending}
>
<ArrowRightLeft className="mr-2 size-4" />
Transfer to {selectedServer?.name}
</Button>
</div>
</div>
)}
{/* Step 4: Transfer in progress */}
{step === "transfer" && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Loader2 className="size-4 animate-spin" />
<span className="text-sm font-medium">
Transferring service...
</span>
</div>
{transferLogs.length > 0 && (
<div className="rounded-lg border bg-muted/50 p-3 max-h-60 overflow-y-auto">
{transferLogs.map((log, i) => (
<div
key={`${log}-${i}`}
className="text-xs font-mono text-muted-foreground"
>
{log}
</div>
))}
</div>
)}
</div>
)}
</>
)}
{/* Confirmation dialog */}
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Service Transfer</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p>
You are about to transfer this {serviceType} to{" "}
<strong>{selectedServer?.name}</strong> (
{selectedServer?.ipAddress}).
</p>
{scanResult && (
<p>
{scanResult.totalFiles} files (
{formatBytes(scanResult.totalTransferSize)}) will be
copied.
</p>
)}
<p className="text-yellow-600 dark:text-yellow-400 font-medium">
The service will experience downtime during this
process. After transfer, you must deploy the service on
the target server.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleTransfer}>
Confirm Transfer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
};

View File

@@ -18,6 +18,7 @@ import { ShowPorts } from "@/components/dashboard/application/advanced/ports/sho
import { ShowRedirects } from "@/components/dashboard/application/advanced/redirects/show-redirects";
import { ShowSecurity } from "@/components/dashboard/application/advanced/security/show-security";
import { ShowBuildServer } from "@/components/dashboard/application/advanced/show-build-server";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
import { ShowTraefikConfig } from "@/components/dashboard/application/advanced/traefik/show-traefik-config";
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
@@ -419,6 +420,11 @@ const Service = (
<ShowSecurity applicationId={applicationId} />
<ShowPorts applicationId={applicationId} />
<ShowTraefikConfig applicationId={applicationId} />
<TransferService
serviceId={applicationId}
serviceType="application"
currentServerId={data?.serverId ?? null}
/>
</div>
</TabsContent>
)}

View File

@@ -22,6 +22,7 @@ import { ShowSchedules } from "@/components/dashboard/application/schedules/show
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
@@ -423,6 +424,11 @@ const Service = (
<ShowVolumes id={composeId} type="compose" />
<ShowImport composeId={composeId} />
<IsolatedDeploymentTab composeId={composeId} />
<TransferService
serviceId={composeId}
serviceType="compose"
currentServerId={data?.serverId ?? null}
/>
</div>
</TabsContent>
)}

View File

@@ -303,6 +303,7 @@ const Mariadb = (
<ShowDatabaseAdvancedSettings
id={mariadbId}
type="mariadb"
serverId={data?.serverId}
/>
</div>
</TabsContent>

View File

@@ -307,6 +307,7 @@ const Mongo = (
<ShowDatabaseAdvancedSettings
id={mongoId}
type="mongo"
serverId={data?.serverId}
/>
</div>
</TabsContent>

View File

@@ -284,6 +284,7 @@ const MySql = (
<ShowDatabaseAdvancedSettings
id={mysqlId}
type="mysql"
serverId={data?.serverId}
/>
</div>
</TabsContent>

View File

@@ -292,6 +292,7 @@ const Postgresql = (
<ShowDatabaseAdvancedSettings
id={postgresId}
type="postgres"
serverId={data?.serverId}
/>
</div>
</TabsContent>

View File

@@ -296,6 +296,7 @@ const Redis = (
<ShowDatabaseAdvancedSettings
id={redisId}
type="redis"
serverId={data?.serverId}
/>
</div>
</TabsContent>

View File

@@ -28,6 +28,8 @@ import {
updateDeploymentStatus,
writeConfig,
writeConfigRemote,
scanServiceForTransfer,
executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -62,6 +64,7 @@ import {
apiSaveGithubProvider,
apiSaveGitlabProvider,
apiSaveGitProvider,
apiTransferApplication,
apiUpdateApplication,
applications,
environments,
@@ -1137,4 +1140,138 @@ export const applicationRouter = createTRPCRouter({
application.serverId,
);
}),
transferScan: protectedProcedure
.input(apiTransferApplication.pick({ applicationId: true, targetServerId: true }))
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["delete"],
});
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return await scanServiceForTransfer({
serviceId: input.applicationId,
serviceType: "application",
appName: application.appName,
sourceServerId: application.serverId,
targetServerId: input.targetServerId,
});
}),
transferWithLogs: protectedProcedure
.input(apiTransferApplication)
.subscription(async function* ({ input, ctx, signal }) {
const application = await findApplicationById(input.applicationId);
await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["delete"],
});
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
const queue: string[] = [];
let done = false;
executeTransfer(
{
serviceId: input.applicationId,
serviceType: "application",
appName: application.appName,
sourceServerId: application.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
(progress) => {
queue.push(JSON.stringify(progress));
},
)
.then(async (result) => {
if (result.success) {
await db
.update(applications)
.set({ serverId: input.targetServerId })
.where(eq(applications.applicationId, input.applicationId));
queue.push("Transfer completed successfully!");
} else {
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
}
})
.catch((error) => {
queue.push(
`Transfer error: ${error instanceof Error ? error.message : String(error)}`,
);
})
.finally(() => {
done = true;
});
while (!done || queue.length > 0) {
if (queue.length > 0) {
yield queue.shift()!;
} else {
await new Promise((r) => setTimeout(r, 50));
}
if (signal?.aborted) {
return;
}
}
}),
transfer: protectedProcedure
.input(apiTransferApplication)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["delete"],
});
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
const result = await executeTransfer(
{
serviceId: input.applicationId,
serviceType: "application",
appName: application.appName,
sourceServerId: application.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Transfer failed: ${result.errors.join(", ")}`,
});
}
await db
.update(applications)
.set({ serverId: input.targetServerId })
.where(eq(applications.applicationId, input.applicationId));
return { success: true };
}),
});

View File

@@ -32,6 +32,8 @@ import {
stopCompose,
updateCompose,
updateDeploymentStatus,
scanServiceForTransfer,
executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -63,6 +65,7 @@ import {
apiRandomizeCompose,
apiRedeployCompose,
apiSaveEnvironmentVariablesCompose,
apiTransferCompose,
apiUpdateCompose,
compose as composeTable,
environments,
@@ -1171,4 +1174,138 @@ export const composeRouter = createTRPCRouter({
true,
);
}),
transferScan: protectedProcedure
.input(apiTransferCompose.pick({ composeId: true, targetServerId: true }))
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["delete"],
});
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
return await scanServiceForTransfer({
serviceId: input.composeId,
serviceType: "compose",
appName: compose.appName,
sourceServerId: compose.serverId,
targetServerId: input.targetServerId,
});
}),
transferWithLogs: protectedProcedure
.input(apiTransferCompose)
.subscription(async function* ({ input, ctx, signal }) {
const compose = await findComposeById(input.composeId);
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["delete"],
});
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
const queue: string[] = [];
let done = false;
executeTransfer(
{
serviceId: input.composeId,
serviceType: "compose",
appName: compose.appName,
sourceServerId: compose.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
(progress) => {
queue.push(JSON.stringify(progress));
},
)
.then(async (result) => {
if (result.success) {
await db
.update(composeTable)
.set({ serverId: input.targetServerId })
.where(eq(composeTable.composeId, input.composeId));
queue.push("Transfer completed successfully!");
} else {
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
}
})
.catch((error) => {
queue.push(
`Transfer error: ${error instanceof Error ? error.message : String(error)}`,
);
})
.finally(() => {
done = true;
});
while (!done || queue.length > 0) {
if (queue.length > 0) {
yield queue.shift()!;
} else {
await new Promise((r) => setTimeout(r, 50));
}
if (signal?.aborted) {
return;
}
}
}),
transfer: protectedProcedure
.input(apiTransferCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["delete"],
});
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
const result = await executeTransfer(
{
serviceId: input.composeId,
serviceType: "compose",
appName: compose.appName,
sourceServerId: compose.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Transfer failed: ${result.errors.join(", ")}`,
});
}
await db
.update(composeTable)
.set({ serverId: input.targetServerId })
.where(eq(composeTable.composeId, input.composeId));
return { success: true };
}),
});

View File

@@ -21,6 +21,8 @@ import {
stopService,
stopServiceRemote,
updateMariadbById,
scanServiceForTransfer,
executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -44,6 +46,7 @@ import {
apiResetMariadb,
apiSaveEnvironmentVariablesMariaDB,
apiSaveExternalPortMariaDB,
apiTransferMariadb,
apiUpdateMariaDB,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
@@ -626,4 +629,120 @@ export const mariadbRouter = createTRPCRouter({
mariadb.serverId,
);
}),
transferScan: protectedProcedure
.input(apiTransferMariadb.pick({ mariadbId: true, targetServerId: true }))
.mutation(async ({ input, ctx }) => {
const mariadb = await findMariadbById(input.mariadbId);
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
service: ["delete"],
});
if (
mariadb.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MariaDB",
});
}
return await scanServiceForTransfer({
serviceId: input.mariadbId,
serviceType: "mariadb",
appName: mariadb.appName,
sourceServerId: mariadb.serverId,
targetServerId: input.targetServerId,
});
}),
transferWithLogs: protectedProcedure
.input(apiTransferMariadb)
.subscription(async function* ({ input, ctx, signal }) {
const mariadb = await findMariadbById(input.mariadbId);
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
service: ["delete"],
});
if (
mariadb.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MariaDB",
});
}
const queue: string[] = [];
let done = false;
executeTransfer(
{
serviceId: input.mariadbId,
serviceType: "mariadb",
appName: mariadb.appName,
sourceServerId: mariadb.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
(progress) => { queue.push(JSON.stringify(progress)); },
)
.then(async (result) => {
if (result.success) {
await db
.update(mariadbTable)
.set({ serverId: input.targetServerId })
.where(eq(mariadbTable.mariadbId, input.mariadbId));
queue.push("Transfer completed successfully!");
} else {
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
}
})
.catch((error) => {
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
})
.finally(() => { done = true; });
while (!done || queue.length > 0) {
if (queue.length > 0) { yield queue.shift()!; }
else { await new Promise((r) => setTimeout(r, 50)); }
if (signal?.aborted) { return; }
}
}),
transfer: protectedProcedure
.input(apiTransferMariadb)
.mutation(async ({ input, ctx }) => {
const mariadb = await findMariadbById(input.mariadbId);
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
service: ["delete"],
});
if (
mariadb.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MariaDB",
});
}
const result = await executeTransfer(
{
serviceId: input.mariadbId,
serviceType: "mariadb",
appName: mariadb.appName,
sourceServerId: mariadb.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Transfer failed: ${result.errors.join(", ")}`,
});
}
await db
.update(mariadbTable)
.set({ serverId: input.targetServerId })
.where(eq(mariadbTable.mariadbId, input.mariadbId));
return { success: true };
}),
});

View File

@@ -21,6 +21,8 @@ import {
stopService,
stopServiceRemote,
updateMongoById,
scanServiceForTransfer,
executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -43,6 +45,7 @@ import {
apiResetMongo,
apiSaveEnvironmentVariablesMongo,
apiSaveExternalPortMongo,
apiTransferMongo,
apiUpdateMongo,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
@@ -637,4 +640,120 @@ export const mongoRouter = createTRPCRouter({
mongo.serverId,
);
}),
transferScan: protectedProcedure
.input(apiTransferMongo.pick({ mongoId: true, targetServerId: true }))
.mutation(async ({ input, ctx }) => {
const mongo = await findMongoById(input.mongoId);
await checkServicePermissionAndAccess(ctx, input.mongoId, {
service: ["delete"],
});
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MongoDB",
});
}
return await scanServiceForTransfer({
serviceId: input.mongoId,
serviceType: "mongo",
appName: mongo.appName,
sourceServerId: mongo.serverId,
targetServerId: input.targetServerId,
});
}),
transferWithLogs: protectedProcedure
.input(apiTransferMongo)
.subscription(async function* ({ input, ctx, signal }) {
const mongo = await findMongoById(input.mongoId);
await checkServicePermissionAndAccess(ctx, input.mongoId, {
service: ["delete"],
});
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MongoDB",
});
}
const queue: string[] = [];
let done = false;
executeTransfer(
{
serviceId: input.mongoId,
serviceType: "mongo",
appName: mongo.appName,
sourceServerId: mongo.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
(progress) => { queue.push(JSON.stringify(progress)); },
)
.then(async (result) => {
if (result.success) {
await db
.update(mongoTable)
.set({ serverId: input.targetServerId })
.where(eq(mongoTable.mongoId, input.mongoId));
queue.push("Transfer completed successfully!");
} else {
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
}
})
.catch((error) => {
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
})
.finally(() => { done = true; });
while (!done || queue.length > 0) {
if (queue.length > 0) { yield queue.shift()!; }
else { await new Promise((r) => setTimeout(r, 50)); }
if (signal?.aborted) { return; }
}
}),
transfer: protectedProcedure
.input(apiTransferMongo)
.mutation(async ({ input, ctx }) => {
const mongo = await findMongoById(input.mongoId);
await checkServicePermissionAndAccess(ctx, input.mongoId, {
service: ["delete"],
});
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MongoDB",
});
}
const result = await executeTransfer(
{
serviceId: input.mongoId,
serviceType: "mongo",
appName: mongo.appName,
sourceServerId: mongo.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Transfer failed: ${result.errors.join(", ")}`,
});
}
await db
.update(mongoTable)
.set({ serverId: input.targetServerId })
.where(eq(mongoTable.mongoId, input.mongoId));
return { success: true };
}),
});

View File

@@ -21,6 +21,8 @@ import {
stopServiceRemote,
updateMySqlById,
getAccessibleServerIds,
scanServiceForTransfer,
executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -43,6 +45,7 @@ import {
apiResetMysql,
apiSaveEnvironmentVariablesMySql,
apiSaveExternalPortMySql,
apiTransferMysql,
apiUpdateMySql,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
@@ -640,4 +643,120 @@ export const mysqlRouter = createTRPCRouter({
mysql.serverId,
);
}),
transferScan: protectedProcedure
.input(apiTransferMysql.pick({ mysqlId: true, targetServerId: true }))
.mutation(async ({ input, ctx }) => {
const mysql = await findMySqlById(input.mysqlId);
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
service: ["delete"],
});
if (
mysql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MySQL",
});
}
return await scanServiceForTransfer({
serviceId: input.mysqlId,
serviceType: "mysql",
appName: mysql.appName,
sourceServerId: mysql.serverId,
targetServerId: input.targetServerId,
});
}),
transferWithLogs: protectedProcedure
.input(apiTransferMysql)
.subscription(async function* ({ input, ctx, signal }) {
const mysql = await findMySqlById(input.mysqlId);
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
service: ["delete"],
});
if (
mysql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MySQL",
});
}
const queue: string[] = [];
let done = false;
executeTransfer(
{
serviceId: input.mysqlId,
serviceType: "mysql",
appName: mysql.appName,
sourceServerId: mysql.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
(progress) => { queue.push(JSON.stringify(progress)); },
)
.then(async (result) => {
if (result.success) {
await db
.update(mysqlTable)
.set({ serverId: input.targetServerId })
.where(eq(mysqlTable.mysqlId, input.mysqlId));
queue.push("Transfer completed successfully!");
} else {
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
}
})
.catch((error) => {
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
})
.finally(() => { done = true; });
while (!done || queue.length > 0) {
if (queue.length > 0) { yield queue.shift()!; }
else { await new Promise((r) => setTimeout(r, 50)); }
if (signal?.aborted) { return; }
}
}),
transfer: protectedProcedure
.input(apiTransferMysql)
.mutation(async ({ input, ctx }) => {
const mysql = await findMySqlById(input.mysqlId);
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
service: ["delete"],
});
if (
mysql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MySQL",
});
}
const result = await executeTransfer(
{
serviceId: input.mysqlId,
serviceType: "mysql",
appName: mysql.appName,
sourceServerId: mysql.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Transfer failed: ${result.errors.join(", ")}`,
});
}
await db
.update(mysqlTable)
.set({ serverId: input.targetServerId })
.where(eq(mysqlTable.mysqlId, input.mysqlId));
return { success: true };
}),
});

View File

@@ -22,6 +22,8 @@ import {
stopServiceRemote,
updatePostgresById,
getAccessibleServerIds,
scanServiceForTransfer,
executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -44,6 +46,7 @@ import {
apiResetPostgres,
apiSaveEnvironmentVariablesPostgres,
apiSaveExternalPortPostgres,
apiTransferPostgres,
apiUpdatePostgres,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
@@ -650,4 +653,120 @@ export const postgresRouter = createTRPCRouter({
postgres.serverId,
);
}),
transferScan: protectedProcedure
.input(apiTransferPostgres.pick({ postgresId: true, targetServerId: true }))
.mutation(async ({ input, ctx }) => {
const postgres = await findPostgresById(input.postgresId);
await checkServicePermissionAndAccess(ctx, input.postgresId, {
service: ["delete"],
});
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this Postgres",
});
}
return await scanServiceForTransfer({
serviceId: input.postgresId,
serviceType: "postgres",
appName: postgres.appName,
sourceServerId: postgres.serverId,
targetServerId: input.targetServerId,
});
}),
transferWithLogs: protectedProcedure
.input(apiTransferPostgres)
.subscription(async function* ({ input, ctx, signal }) {
const postgres = await findPostgresById(input.postgresId);
await checkServicePermissionAndAccess(ctx, input.postgresId, {
service: ["delete"],
});
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this Postgres",
});
}
const queue: string[] = [];
let done = false;
executeTransfer(
{
serviceId: input.postgresId,
serviceType: "postgres",
appName: postgres.appName,
sourceServerId: postgres.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
(progress) => { queue.push(JSON.stringify(progress)); },
)
.then(async (result) => {
if (result.success) {
await db
.update(postgresTable)
.set({ serverId: input.targetServerId })
.where(eq(postgresTable.postgresId, input.postgresId));
queue.push("Transfer completed successfully!");
} else {
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
}
})
.catch((error) => {
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
})
.finally(() => { done = true; });
while (!done || queue.length > 0) {
if (queue.length > 0) { yield queue.shift()!; }
else { await new Promise((r) => setTimeout(r, 50)); }
if (signal?.aborted) { return; }
}
}),
transfer: protectedProcedure
.input(apiTransferPostgres)
.mutation(async ({ input, ctx }) => {
const postgres = await findPostgresById(input.postgresId);
await checkServicePermissionAndAccess(ctx, input.postgresId, {
service: ["delete"],
});
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this Postgres",
});
}
const result = await executeTransfer(
{
serviceId: input.postgresId,
serviceType: "postgres",
appName: postgres.appName,
sourceServerId: postgres.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Transfer failed: ${result.errors.join(", ")}`,
});
}
await db
.update(postgresTable)
.set({ serverId: input.targetServerId })
.where(eq(postgresTable.postgresId, input.postgresId));
return { success: true };
}),
});

View File

@@ -20,6 +20,8 @@ import {
stopServiceRemote,
updateRedisById,
getAccessibleServerIds,
scanServiceForTransfer,
executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -42,6 +44,7 @@ import {
apiResetRedis,
apiSaveEnvironmentVariablesRedis,
apiSaveExternalPortRedis,
apiTransferRedis,
apiUpdateRedis,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
@@ -623,4 +626,120 @@ export const redisRouter = createTRPCRouter({
redis.serverId,
);
}),
transferScan: protectedProcedure
.input(apiTransferRedis.pick({ redisId: true, targetServerId: true }))
.mutation(async ({ input, ctx }) => {
const redis = await findRedisById(input.redisId);
await checkServicePermissionAndAccess(ctx, input.redisId, {
service: ["delete"],
});
if (
redis.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this Redis",
});
}
return await scanServiceForTransfer({
serviceId: input.redisId,
serviceType: "redis",
appName: redis.appName,
sourceServerId: redis.serverId,
targetServerId: input.targetServerId,
});
}),
transferWithLogs: protectedProcedure
.input(apiTransferRedis)
.subscription(async function* ({ input, ctx, signal }) {
const redis = await findRedisById(input.redisId);
await checkServicePermissionAndAccess(ctx, input.redisId, {
service: ["delete"],
});
if (
redis.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this Redis",
});
}
const queue: string[] = [];
let done = false;
executeTransfer(
{
serviceId: input.redisId,
serviceType: "redis",
appName: redis.appName,
sourceServerId: redis.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
(progress) => { queue.push(JSON.stringify(progress)); },
)
.then(async (result) => {
if (result.success) {
await db
.update(redisTable)
.set({ serverId: input.targetServerId })
.where(eq(redisTable.redisId, input.redisId));
queue.push("Transfer completed successfully!");
} else {
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
}
})
.catch((error) => {
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
})
.finally(() => { done = true; });
while (!done || queue.length > 0) {
if (queue.length > 0) { yield queue.shift()!; }
else { await new Promise((r) => setTimeout(r, 50)); }
if (signal?.aborted) { return; }
}
}),
transfer: protectedProcedure
.input(apiTransferRedis)
.mutation(async ({ input, ctx }) => {
const redis = await findRedisById(input.redisId);
await checkServicePermissionAndAccess(ctx, input.redisId, {
service: ["delete"],
});
if (
redis.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this Redis",
});
}
const result = await executeTransfer(
{
serviceId: input.redisId,
serviceType: "redis",
appName: redis.appName,
sourceServerId: redis.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Transfer failed: ${result.errors.join(", ")}`,
});
}
await db
.update(redisTable)
.set({ serverId: input.targetServerId })
.where(eq(redisTable.redisId, input.redisId));
return { success: true };
}),
});