mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-18 13:45:23 +02:00
Compare commits
17 Commits
feat/add-n
...
feat/servi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2880fb9748 | ||
|
|
fcbd226796 | ||
|
|
ddff8b9de7 | ||
|
|
90f97912a4 | ||
|
|
9af745ce67 | ||
|
|
d99f2cd460 | ||
|
|
d234558822 | ||
|
|
7f25ddca44 | ||
|
|
638b3dd546 | ||
|
|
1a8fd8396d | ||
|
|
385850f354 | ||
|
|
a48306a2c6 | ||
|
|
89737e7b65 | ||
|
|
00c708483e | ||
|
|
ddf570a807 | ||
|
|
f8eb2ba4ba | ||
|
|
9f07f8e9e1 |
@@ -0,0 +1,290 @@
|
||||
import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { api } from "@/utils/api";
|
||||
import { ShowContainerConfig } from "@/components/dashboard/docker/config/show-container-config";
|
||||
import { ShowContainerMounts } from "@/components/dashboard/docker/mounts/show-container-mounts";
|
||||
import { ShowContainerNetworks } from "@/components/dashboard/docker/networks/show-container-networks";
|
||||
import { DockerTerminalModal } from "@/components/dashboard/docker/terminal/docker-terminal-modal";
|
||||
|
||||
const DockerLogsId = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/docker/logs/docker-logs-id").then(
|
||||
(e) => e.DockerLogsId,
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
interface Props {
|
||||
appName: string;
|
||||
serverId?: string;
|
||||
appType: "stack" | "docker-compose";
|
||||
}
|
||||
|
||||
export const ShowComposeContainers = ({
|
||||
appName,
|
||||
appType,
|
||||
serverId,
|
||||
}: Props) => {
|
||||
const { data, isPending, refetch } =
|
||||
api.docker.getContainersByAppNameMatch.useQuery(
|
||||
{
|
||||
appName,
|
||||
appType,
|
||||
serverId,
|
||||
},
|
||||
{
|
||||
enabled: !!appName,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Containers</CardTitle>
|
||||
<CardDescription>
|
||||
Inspect each container in this compose and run basic lifecycle
|
||||
actions.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => refetch()}
|
||||
disabled={isPending}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isPending ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isPending ? (
|
||||
<div className="flex items-center justify-center h-[20vh]">
|
||||
<Loader2 className="animate-spin h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
) : !data || data.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-[20vh]">
|
||||
<span className="text-muted-foreground">
|
||||
No containers found. Deploy the compose to see containers here.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>State</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Container ID</TableHead>
|
||||
<TableHead className="text-right" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((container) => (
|
||||
<ContainerRow
|
||||
key={container.containerId}
|
||||
container={container}
|
||||
serverId={serverId}
|
||||
onActionComplete={() => refetch()}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface ContainerRowProps {
|
||||
container: {
|
||||
containerId: string;
|
||||
name: string;
|
||||
state: string;
|
||||
status: string;
|
||||
};
|
||||
serverId?: string;
|
||||
onActionComplete: () => void;
|
||||
}
|
||||
|
||||
const ContainerRow = ({
|
||||
container,
|
||||
serverId,
|
||||
onActionComplete,
|
||||
}: ContainerRowProps) => {
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
const restartMutation = api.docker.restartContainer.useMutation();
|
||||
const startMutation = api.docker.startContainer.useMutation();
|
||||
const stopMutation = api.docker.stopContainer.useMutation();
|
||||
const killMutation = api.docker.killContainer.useMutation();
|
||||
|
||||
const handleAction = async (
|
||||
action: string,
|
||||
mutationFn: typeof restartMutation,
|
||||
) => {
|
||||
setActionLoading(action);
|
||||
try {
|
||||
await mutationFn.mutateAsync({
|
||||
containerId: container.containerId,
|
||||
serverId,
|
||||
});
|
||||
toast.success(`Container ${action} successfully`);
|
||||
onActionComplete();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Failed to ${action} container: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">{container.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
container.state === "running"
|
||||
? "default"
|
||||
: container.state === "exited"
|
||||
? "secondary"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{container.state}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{container.status}</TableCell>
|
||||
<TableCell className="font-mono text-sm text-muted-foreground">
|
||||
{container.containerId}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
{actionLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
View Logs
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<ShowContainerConfig
|
||||
containerId={container.containerId}
|
||||
serverId={serverId || ""}
|
||||
/>
|
||||
<ShowContainerMounts
|
||||
containerId={container.containerId}
|
||||
serverId={serverId || ""}
|
||||
/>
|
||||
<ShowContainerNetworks
|
||||
containerId={container.containerId}
|
||||
serverId={serverId || ""}
|
||||
/>
|
||||
<DockerTerminalModal
|
||||
containerId={container.containerId}
|
||||
serverId={serverId || ""}
|
||||
>
|
||||
Terminal
|
||||
</DockerTerminalModal>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
disabled={actionLoading !== null}
|
||||
onClick={() => handleAction("restart", restartMutation)}
|
||||
>
|
||||
Restart
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
disabled={actionLoading !== null}
|
||||
onClick={() => handleAction("start", startMutation)}
|
||||
>
|
||||
Start
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
disabled={actionLoading !== null}
|
||||
onClick={() => handleAction("stop", stopMutation)}
|
||||
>
|
||||
Stop
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer text-red-500 focus:text-red-600"
|
||||
disabled={actionLoading !== null}
|
||||
onClick={() => handleAction("kill", killMutation)}
|
||||
>
|
||||
Kill
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DialogContent className="sm:max-w-7xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>View Logs</DialogTitle>
|
||||
<DialogDescription>Logs for {container.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<DockerLogsId
|
||||
containerId={container.containerId}
|
||||
serverId={serverId}
|
||||
runType="native"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
containerId: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
interface Mount {
|
||||
Type: string;
|
||||
Source: string;
|
||||
Destination: string;
|
||||
Mode: string;
|
||||
RW: boolean;
|
||||
Propagation: string;
|
||||
Name?: string;
|
||||
Driver?: string;
|
||||
}
|
||||
|
||||
export const ShowContainerMounts = ({ containerId, serverId }: Props) => {
|
||||
const { data } = api.docker.getConfig.useQuery(
|
||||
{
|
||||
containerId,
|
||||
serverId,
|
||||
},
|
||||
{
|
||||
enabled: !!containerId,
|
||||
},
|
||||
);
|
||||
|
||||
const mounts: Mount[] = data?.Mounts ?? [];
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
View Mounts
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Container Mounts</DialogTitle>
|
||||
<DialogDescription>
|
||||
Volume and bind mounts for this container
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="overflow-auto max-h-[70vh]">
|
||||
{mounts.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
No mounts found for this container.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead>Destination</TableHead>
|
||||
<TableHead>Mode</TableHead>
|
||||
<TableHead>Read/Write</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mounts.map((mount, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{mount.Type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs max-w-[250px] truncate">
|
||||
{mount.Name || mount.Source}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs max-w-[250px] truncate">
|
||||
{mount.Destination}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{mount.Mode || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={mount.RW ? "default" : "secondary"}>
|
||||
{mount.RW ? "RW" : "RO"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
containerId: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
interface Network {
|
||||
IPAMConfig: unknown;
|
||||
Links: unknown;
|
||||
Aliases: string[] | null;
|
||||
MacAddress: string;
|
||||
NetworkID: string;
|
||||
EndpointID: string;
|
||||
Gateway: string;
|
||||
IPAddress: string;
|
||||
IPPrefixLen: number;
|
||||
IPv6Gateway: string;
|
||||
GlobalIPv6Address: string;
|
||||
GlobalIPv6PrefixLen: number;
|
||||
DriverOpts: unknown;
|
||||
}
|
||||
|
||||
export const ShowContainerNetworks = ({ containerId, serverId }: Props) => {
|
||||
const { data } = api.docker.getConfig.useQuery(
|
||||
{
|
||||
containerId,
|
||||
serverId,
|
||||
},
|
||||
{
|
||||
enabled: !!containerId,
|
||||
},
|
||||
);
|
||||
|
||||
const networks: Record<string, Network> =
|
||||
data?.NetworkSettings?.Networks ?? {};
|
||||
const entries = Object.entries(networks);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
View Networks
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Container Networks</DialogTitle>
|
||||
<DialogDescription>
|
||||
Networks attached to this container
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="overflow-auto max-h-[70vh]">
|
||||
{entries.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
No networks found for this container.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Network</TableHead>
|
||||
<TableHead>IP Address</TableHead>
|
||||
<TableHead>Gateway</TableHead>
|
||||
<TableHead>MAC Address</TableHead>
|
||||
<TableHead>Aliases</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map(([name, network]) => (
|
||||
<TableRow key={name}>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{name}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{network.IPAddress
|
||||
? `${network.IPAddress}/${network.IPPrefixLen}`
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{network.Gateway || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{network.MacAddress || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{network.Aliases?.join(", ") || "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ShowContainerConfig } from "../config/show-container-config";
|
||||
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
|
||||
import { ShowContainerMounts } from "../mounts/show-container-mounts";
|
||||
import { ShowContainerNetworks } from "../networks/show-container-networks";
|
||||
import { RemoveContainerDialog } from "../remove/remove-container";
|
||||
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
|
||||
import { UploadFileModal } from "../upload/upload-file-modal";
|
||||
@@ -123,6 +125,14 @@ export const columns: ColumnDef<Container>[] = [
|
||||
containerId={container.containerId}
|
||||
serverId={container.serverId || ""}
|
||||
/>
|
||||
<ShowContainerMounts
|
||||
containerId={container.containerId}
|
||||
serverId={container.serverId || ""}
|
||||
/>
|
||||
<ShowContainerNetworks
|
||||
containerId={container.containerId}
|
||||
serverId={container.serverId || ""}
|
||||
/>
|
||||
<DockerTerminalModal
|
||||
containerId={container.containerId}
|
||||
serverId={container.serverId || ""}
|
||||
|
||||
@@ -1,563 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Network, Pencil, Plus } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const networkDriverEnum = [
|
||||
"bridge",
|
||||
"host",
|
||||
"overlay",
|
||||
"macvlan",
|
||||
"none",
|
||||
"ipvlan",
|
||||
] as const;
|
||||
|
||||
/** Sentinel for "no scope" */
|
||||
const SCOPE_EMPTY = "__scope_none__";
|
||||
|
||||
const ipamConfigEntrySchema = z.object({
|
||||
subnet: z.string().optional(),
|
||||
ipRange: z.string().optional(),
|
||||
gateway: z.string().optional(),
|
||||
});
|
||||
|
||||
const networkFormSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
driver: z.enum(networkDriverEnum).default("bridge"),
|
||||
scope: z.string().optional(),
|
||||
serverId: z.string().optional(),
|
||||
internal: z.boolean().default(false),
|
||||
attachable: z.boolean().default(false),
|
||||
ingress: z.boolean().default(false),
|
||||
configOnly: z.boolean().default(false),
|
||||
enableIPv4: z.boolean().default(true),
|
||||
enableIPv6: z.boolean().default(false),
|
||||
ipamDriver: z.string().optional(),
|
||||
ipamConfig: z.array(ipamConfigEntrySchema).default([]),
|
||||
});
|
||||
|
||||
type NetworkFormValues = z.infer<typeof networkFormSchema>;
|
||||
|
||||
const defaultValues: NetworkFormValues = {
|
||||
name: "",
|
||||
driver: "bridge",
|
||||
scope: SCOPE_EMPTY,
|
||||
serverId: undefined,
|
||||
internal: false,
|
||||
attachable: false,
|
||||
ingress: false,
|
||||
configOnly: false,
|
||||
enableIPv4: true,
|
||||
enableIPv6: false,
|
||||
ipamDriver: "",
|
||||
ipamConfig: [],
|
||||
};
|
||||
|
||||
interface HandleNetworkProps {
|
||||
networkId?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const HandleNetwork = ({ networkId, children }: HandleNetworkProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const utils = api.useUtils();
|
||||
const isEdit = !!networkId;
|
||||
|
||||
const { data: servers } = api.server.all.useQuery();
|
||||
const { data: network, isLoading: isLoadingNetwork } =
|
||||
api.network.one.useQuery(
|
||||
{ networkId: networkId! },
|
||||
{ enabled: isEdit && !!networkId },
|
||||
);
|
||||
|
||||
const { mutateAsync, isLoading: isPending } = networkId
|
||||
? api.network.update.useMutation()
|
||||
: api.network.create.useMutation();
|
||||
|
||||
const form = useForm<NetworkFormValues>({
|
||||
resolver: zodResolver(networkFormSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const ipamConfigFieldArray = useFieldArray({
|
||||
control: form.control,
|
||||
name: "ipamConfig",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && network && isOpen) {
|
||||
const ipam = network.ipam ?? {};
|
||||
const ipamConfigArr = (ipam.config ?? []).map((c) => ({
|
||||
subnet: c.subnet ?? "",
|
||||
ipRange: c.ipRange ?? "",
|
||||
gateway: c.gateway ?? "",
|
||||
}));
|
||||
form.reset({
|
||||
...defaultValues,
|
||||
name: network.name,
|
||||
driver: network.driver,
|
||||
scope: network.scope ?? SCOPE_EMPTY,
|
||||
serverId: network.serverId || undefined,
|
||||
internal: network.internal,
|
||||
attachable: network.attachable,
|
||||
enableIPv4: network.enableIPv4,
|
||||
enableIPv6: network.enableIPv6,
|
||||
ipamDriver: ipam.driver ?? "",
|
||||
ipamConfig: ipamConfigArr,
|
||||
ingress: network.ingress,
|
||||
configOnly: network.configOnly,
|
||||
});
|
||||
}
|
||||
}, [isEdit, isOpen, network, form]);
|
||||
|
||||
const onSubmit = async (data: NetworkFormValues) => {
|
||||
const scope =
|
||||
data.scope && data.scope !== SCOPE_EMPTY ? data.scope : undefined;
|
||||
|
||||
try {
|
||||
await mutateAsync({
|
||||
networkId: networkId ?? "",
|
||||
name: data.name,
|
||||
driver: data.driver,
|
||||
scope,
|
||||
serverId: data.serverId || undefined,
|
||||
internal: data.internal,
|
||||
attachable: data.attachable,
|
||||
ingress: data.ingress,
|
||||
configOnly: data.configOnly,
|
||||
enableIPv4: data.enableIPv4,
|
||||
enableIPv6: data.enableIPv6,
|
||||
ipam: {
|
||||
driver: data.ipamDriver,
|
||||
config: data.ipamConfig,
|
||||
},
|
||||
});
|
||||
|
||||
await utils.network.all.invalidate();
|
||||
if (networkId) await utils.network.one.invalidate({ networkId });
|
||||
setIsOpen(false);
|
||||
form.reset(defaultValues);
|
||||
} catch {
|
||||
toast.error(isEdit ? "Error updating network" : "Error creating network");
|
||||
}
|
||||
};
|
||||
|
||||
const trigger =
|
||||
children ??
|
||||
(isEdit ? (
|
||||
<Button size="sm" variant="outline">
|
||||
<Pencil className=" size-4" />
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<Button>
|
||||
<Plus className=" size-4" />
|
||||
Add network
|
||||
</Button>
|
||||
));
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Network className="size-5 text-muted-foreground" />
|
||||
{isEdit ? "Edit network" : "Add network"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? "Update this Docker network. Changes apply to name, driver, and server assignment."
|
||||
: "Create a new Docker network for your organization. You can optionally assign it to a server."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isEdit && isLoadingNetwork ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
Loading network…
|
||||
</div>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex w-full flex-col gap-6"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-network" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="driver"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Driver</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select driver" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{networkDriverEnum.map((d) => (
|
||||
<SelectItem key={d} value={d}>
|
||||
{d}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value ?? undefined}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select server" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{!isCloud && (
|
||||
<SelectItem value={undefined}>
|
||||
Dokploy server
|
||||
</SelectItem>
|
||||
)}
|
||||
{servers?.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverId}
|
||||
>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription className="text-muted-foreground">
|
||||
{isCloud
|
||||
? "Server where this network will be created."
|
||||
: "Dokploy server is the default local server; or choose a specific server."}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scope"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Scope (optional)</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value ?? SCOPE_EMPTY}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select scope" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={SCOPE_EMPTY}>None</SelectItem>
|
||||
<SelectItem value="local">local</SelectItem>
|
||||
<SelectItem value="swarm">swarm</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="internal"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Internal</FormLabel>
|
||||
<FormDescription className="text-muted-foreground">
|
||||
Restrict external access; containers on this network
|
||||
cannot reach external networks.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="attachable"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Attachable</FormLabel>
|
||||
<FormDescription className="text-muted-foreground">
|
||||
Allow standalone containers to attach to this network
|
||||
(e.g. in Swarm, not only services).
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableIPv4"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Enable IPv4</FormLabel>
|
||||
<FormDescription className="text-muted-foreground">
|
||||
Enable IPv4 addressing on the network.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableIPv6"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Enable IPv6</FormLabel>
|
||||
<FormDescription className="text-muted-foreground">
|
||||
Enable IPv6 addressing on the network.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ingress"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Ingress</FormLabel>
|
||||
<FormDescription className="text-muted-foreground">
|
||||
Use as the routing-mesh network in Swarm mode (load
|
||||
balancing between nodes).
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="configOnly"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Config only</FormLabel>
|
||||
<FormDescription className="text-muted-foreground">
|
||||
Create a placeholder network whose config is reused by
|
||||
other networks; cannot run containers on it.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<FormLabel>IPAM</FormLabel>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ipamDriver"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">
|
||||
Driver (optional)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="default" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<FormLabel className="text-muted-foreground">
|
||||
Config (subnet / gateway / IP range)
|
||||
</FormLabel>
|
||||
{ipamConfigFieldArray.fields.map((field, index) => (
|
||||
<div key={field.id} className="flex flex-wrap gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ipamConfig.${index}.subnet`}
|
||||
render={({ field: f }) => (
|
||||
<FormItem className="min-w-[140px] flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...f}
|
||||
placeholder="Subnet (e.g. 172.20.0.0/16)"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ipamConfig.${index}.ipRange`}
|
||||
render={({ field: f }) => (
|
||||
<FormItem className="min-w-[120px] flex-1">
|
||||
<FormControl>
|
||||
<Input {...f} placeholder="IP range" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ipamConfig.${index}.gateway`}
|
||||
render={({ field: f }) => (
|
||||
<FormItem className="min-w-[120px] flex-1">
|
||||
<FormControl>
|
||||
<Input {...f} placeholder="Gateway" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => ipamConfigFieldArray.remove(index)}
|
||||
>
|
||||
−
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
ipamConfigFieldArray.append({
|
||||
subnet: "",
|
||||
ipRange: "",
|
||||
gateway: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
Add IPAM config
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending
|
||||
? isEdit
|
||||
? "Updating…"
|
||||
: "Creating…"
|
||||
: isEdit
|
||||
? "Update network"
|
||||
: "Create network"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,118 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Network } from "lucide-react";
|
||||
import { HandleNetwork } from "@/components/dashboard/networks/handle-network";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const ShowNetworks = () => {
|
||||
const { data: networks, isLoading } = api.network.all.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Network className="size-6 text-muted-foreground self-center" />
|
||||
Networks
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage Docker networks for your organization. Networks can be
|
||||
scoped to a server (optional).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{networks && networks?.length > 0 && <HandleNetwork />}
|
||||
</div>
|
||||
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
<div className="gap-4 pb-20 w-full">
|
||||
<div className="flex flex-col gap-4 w-full overflow-auto">
|
||||
<div className="rounded-md border">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground h-[55vh]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : !networks?.length ? (
|
||||
<div className="flex min-h-[55vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
<Network className="size-10 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<p className="text-sm font-medium">No networks yet</p>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
Create Docker networks for your organization and
|
||||
optionally attach them to a server. Add your first
|
||||
network to get started.
|
||||
</p>
|
||||
</div>
|
||||
<HandleNetwork />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Driver</TableHead>
|
||||
<TableHead>Scope</TableHead>
|
||||
<TableHead>Internal</TableHead>
|
||||
<TableHead>Attachable</TableHead>
|
||||
<TableHead>Server</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-[80px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{networks.map((n) => (
|
||||
<TableRow key={n.networkId}>
|
||||
<TableCell className="font-medium">
|
||||
{n.name}
|
||||
</TableCell>
|
||||
<TableCell>{n.driver}</TableCell>
|
||||
<TableCell>{n.scope ?? "—"}</TableCell>
|
||||
<TableCell>{n.internal ? "Yes" : "No"}</TableCell>
|
||||
<TableCell>{n.attachable ? "Yes" : "No"}</TableCell>
|
||||
<TableCell>
|
||||
{n.serverId ?? "Dokploy server"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{new Date(n.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<HandleNetwork networkId={n.networkId}>
|
||||
<Button variant="ghost" size="sm">
|
||||
Edit
|
||||
</Button>
|
||||
</HandleNetwork>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
596
apps/dokploy/components/dashboard/shared/transfer-service.tsx
Normal file
596
apps/dokploy/components/dashboard/shared/transfer-service.tsx
Normal file
@@ -0,0 +1,596 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRightLeft,
|
||||
Loader2,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
||||
import type { LogLine } from "@/components/dashboard/docker/logs/utils";
|
||||
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 useScanMutation = (serviceType: ServiceType) => {
|
||||
const mutations = {
|
||||
application: api.application.transferScan.useMutation(),
|
||||
compose: api.compose.transferScan.useMutation(),
|
||||
postgres: api.postgres.transferScan.useMutation(),
|
||||
mysql: api.mysql.transferScan.useMutation(),
|
||||
mariadb: api.mariadb.transferScan.useMutation(),
|
||||
mongo: api.mongo.transferScan.useMutation(),
|
||||
redis: api.redis.transferScan.useMutation(),
|
||||
};
|
||||
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">("select");
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
// Drawer logs state
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||
const [isTransferring, setIsTransferring] = useState(false);
|
||||
|
||||
const { data: servers } = api.server.all.useQuery();
|
||||
const utils = api.useUtils();
|
||||
const scan = useScanMutation(serviceType);
|
||||
|
||||
const idKey = getServiceIdKey(serviceType);
|
||||
|
||||
const availableServers = servers?.filter(
|
||||
(s) => s.serverId !== currentServerId,
|
||||
);
|
||||
|
||||
const selectedServer = servers?.find((s) => s.serverId === targetServerId);
|
||||
|
||||
// Subscription for transfer with logs
|
||||
const subscriptionInput = {
|
||||
[idKey]: serviceId,
|
||||
targetServerId: targetServerId || "placeholder",
|
||||
decisions: {},
|
||||
};
|
||||
|
||||
const useTransferSubscription = (sType: ServiceType) => {
|
||||
api.application.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "application",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
api.compose.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "compose",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
api.postgres.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "postgres",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
api.mysql.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "mysql",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
api.mariadb.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "mariadb",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
api.mongo.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "mongo",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
api.redis.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "redis",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogData = (log: string) => {
|
||||
if (!isDrawerOpen) {
|
||||
setIsDrawerOpen(true);
|
||||
}
|
||||
|
||||
// Try to parse as JSON progress
|
||||
try {
|
||||
const progress = JSON.parse(log);
|
||||
if (progress.message) {
|
||||
const logLine: LogLine = {
|
||||
rawTimestamp: new Date().toISOString(),
|
||||
timestamp: new Date(),
|
||||
message: `[${progress.phase || "transfer"}] ${progress.message}`,
|
||||
};
|
||||
setFilteredLogs((prev) => [...prev, logLine]);
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
// Not JSON, treat as plain text
|
||||
}
|
||||
|
||||
const logLine: LogLine = {
|
||||
rawTimestamp: new Date().toISOString(),
|
||||
timestamp: new Date(),
|
||||
message: log,
|
||||
};
|
||||
setFilteredLogs((prev) => [...prev, logLine]);
|
||||
|
||||
if (
|
||||
log.includes("completed successfully") ||
|
||||
log.includes("Deployment queued") ||
|
||||
log.includes("Deployment started")
|
||||
) {
|
||||
setTimeout(() => {
|
||||
setIsTransferring(false);
|
||||
utils.invalidate();
|
||||
toast.success("Transfer and deployment completed!");
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
if (log.includes("Transfer failed") || log.includes("Transfer error")) {
|
||||
setIsTransferring(false);
|
||||
toast.error("Transfer failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogError = (error: unknown) => {
|
||||
console.error("Transfer subscription error:", error);
|
||||
setIsTransferring(false);
|
||||
const logLine: LogLine = {
|
||||
rawTimestamp: new Date().toISOString(),
|
||||
timestamp: new Date(),
|
||||
message: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
setFilteredLogs((prev) => [...prev, logLine]);
|
||||
};
|
||||
|
||||
// Register the subscription hooks (must be called unconditionally)
|
||||
useTransferSubscription(serviceType);
|
||||
|
||||
const handleScan = async () => {
|
||||
if (!targetServerId) {
|
||||
toast.error("Please select a target server");
|
||||
return;
|
||||
}
|
||||
|
||||
setStep("scan");
|
||||
try {
|
||||
const result = await scan.mutateAsync({
|
||||
[idKey]: serviceId,
|
||||
targetServerId,
|
||||
} as any);
|
||||
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);
|
||||
setFilteredLogs([]);
|
||||
setIsTransferring(true);
|
||||
setIsDrawerOpen(true);
|
||||
|
||||
// Add initial log
|
||||
setFilteredLogs([
|
||||
{
|
||||
rawTimestamp: new Date().toISOString(),
|
||||
timestamp: new Date(),
|
||||
message: `Starting transfer to ${selectedServer?.name} (${selectedServer?.ipAddress})...`,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
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">
|
||||
<span className="text-sm font-medium">Target Server</span>
|
||||
<Select
|
||||
value={targetServerId}
|
||||
onValueChange={(value) => {
|
||||
setTargetServerId(value);
|
||||
setScanResult(null);
|
||||
setStep("select");
|
||||
}}
|
||||
disabled={isTransferring}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
{scanResult.mounts.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Docker Volumes:
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{scanResult.mounts.map((m) => (
|
||||
<Badge
|
||||
key={m.mount.mountId}
|
||||
variant="outline"
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{m.mount.volumeName ||
|
||||
m.mount.hostPath ||
|
||||
m.mount.mountPath}
|
||||
{m.totalSize > 0 && (
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
({formatBytes(m.totalSize)})
|
||||
</span>
|
||||
)}
|
||||
{m.files.length > 0 && (
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
{m.files.length} files
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</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. After transfer completes, the service will be automatically deployed on the target server."
|
||||
: "The service will be unavailable during transfer. After transfer completes, the service will be automatically deployed on the target server."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Transfer button */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setStep("select");
|
||||
setScanResult(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowConfirm(true)}
|
||||
disabled={isTransferring}
|
||||
>
|
||||
<ArrowRightLeft className="mr-2 size-4" />
|
||||
Transfer to {selectedServer?.name}
|
||||
</Button>
|
||||
</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.
|
||||
{scanResult.mounts.length > 0 &&
|
||||
` ${scanResult.mounts.length} volume(s) will be transferred.`}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-yellow-600 dark:text-yellow-400 font-medium">
|
||||
The service will experience downtime during this
|
||||
process. After transfer, the service will be
|
||||
automatically deployed on the target server.
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleTransfer}>
|
||||
Confirm Transfer
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Drawer for transfer logs */}
|
||||
<DrawerLogs
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => {
|
||||
setIsDrawerOpen(false);
|
||||
if (!isTransferring) {
|
||||
setFilteredLogs([]);
|
||||
setStep("select");
|
||||
setScanResult(null);
|
||||
}
|
||||
}}
|
||||
filteredLogs={filteredLogs}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
Loader2,
|
||||
LogIn,
|
||||
type LucideIcon,
|
||||
Network,
|
||||
Package,
|
||||
Palette,
|
||||
PieChart,
|
||||
@@ -207,20 +206,6 @@ const MENU: Menu = {
|
||||
isEnabled: ({ permissions, isCloud }) =>
|
||||
!!(permissions?.docker.read && !isCloud),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Networks",
|
||||
url: "/dashboard/networks",
|
||||
icon: Network,
|
||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||
isEnabled: ({ auth, isCloud }) =>
|
||||
!!(
|
||||
(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canAccessToDocker) &&
|
||||
!isCloud
|
||||
),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Requests",
|
||||
|
||||
198
apps/dokploy/components/ui/context-menu.tsx
Normal file
198
apps/dokploy/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root;
|
||||
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
|
||||
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group;
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
||||
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub;
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
));
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
));
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
ContextMenuCheckboxItem.displayName =
|
||||
ContextMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
));
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
|
||||
|
||||
const ContextMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
ContextMenuShortcut.displayName = "ContextMenuShortcut";
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
CREATE TYPE "public"."networkDriver" AS ENUM('bridge', 'host', 'overlay', 'macvlan', 'none', 'ipvlan');--> statement-breakpoint
|
||||
CREATE TABLE "network" (
|
||||
"networkId" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"driver" "networkDriver" DEFAULT 'bridge' NOT NULL,
|
||||
"scope" text,
|
||||
"internal" boolean DEFAULT false NOT NULL,
|
||||
"attachable" boolean DEFAULT false NOT NULL,
|
||||
"ingress" boolean DEFAULT false NOT NULL,
|
||||
"configOnly" boolean DEFAULT false NOT NULL,
|
||||
"enableIPv4" boolean DEFAULT true NOT NULL,
|
||||
"enableIPv6" boolean DEFAULT false NOT NULL,
|
||||
"ipam" jsonb DEFAULT '{}'::jsonb,
|
||||
"createdAt" text NOT NULL,
|
||||
"organizationId" text NOT NULL,
|
||||
"serverId" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "compose" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "mariadb" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "mongo" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "mysql" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "postgres" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "redis" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "network" ADD CONSTRAINT "network_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "network" ADD CONSTRAINT "network_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1163,13 +1163,6 @@
|
||||
"when": 1775845419261,
|
||||
"tag": "0165_abnormal_greymalkin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 166,
|
||||
"version": "7",
|
||||
"when": 1776117573148,
|
||||
"tag": "0166_pale_multiple_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -67,6 +67,7 @@
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { ShowNetworks } from "@/components/dashboard/networks/show-networks";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
const Dashboard = () => {
|
||||
return <ShowNetworks />;
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
|
||||
Dashboard.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout>{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||
) {
|
||||
if (IS_CLOUD) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { user, session } = await validateRequest(ctx.req);
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { req } = ctx;
|
||||
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: ctx.res as any,
|
||||
db: null as any,
|
||||
session: session as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
try {
|
||||
await helpers.project.all.prefetch();
|
||||
|
||||
if (user.role === "member") {
|
||||
const userR = await helpers.user.one.fetch({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (!userR?.canAccessToDocker) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await helpers.network.all.prefetch();
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Loader2,
|
||||
Play,
|
||||
PlusIcon,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ServerIcon,
|
||||
SquareTerminal,
|
||||
@@ -68,6 +69,14 @@ import {
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -424,6 +433,7 @@ const EnvironmentPage = (
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [deleteVolumes, setDeleteVolumes] = useState(false);
|
||||
const [selectedServerId, setSelectedServerId] = useState<string>("all");
|
||||
const [serviceToDelete, setServiceToDelete] = useState<Services | null>(null);
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedServices.length === filteredServices.length) {
|
||||
@@ -814,6 +824,110 @@ const EnvironmentPage = (
|
||||
setIsBulkActionLoading(false);
|
||||
};
|
||||
|
||||
const getServiceActions = (service: Services) => {
|
||||
switch (service.type) {
|
||||
case "application":
|
||||
return applicationActions;
|
||||
case "compose":
|
||||
return composeActions;
|
||||
case "postgres":
|
||||
return postgresActions;
|
||||
case "mysql":
|
||||
return mysqlActions;
|
||||
case "mariadb":
|
||||
return mariadbActions;
|
||||
case "redis":
|
||||
return redisActions;
|
||||
case "mongo":
|
||||
return mongoActions;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getServiceIdKey = (service: Services) => {
|
||||
switch (service.type) {
|
||||
case "application":
|
||||
return "applicationId";
|
||||
case "compose":
|
||||
return "composeId";
|
||||
case "postgres":
|
||||
return "postgresId";
|
||||
case "mysql":
|
||||
return "mysqlId";
|
||||
case "mariadb":
|
||||
return "mariadbId";
|
||||
case "redis":
|
||||
return "redisId";
|
||||
case "mongo":
|
||||
return "mongoId";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleServiceAction = async (
|
||||
service: Services,
|
||||
action: "start" | "stop" | "deploy",
|
||||
) => {
|
||||
const actions = getServiceActions(service);
|
||||
const idKey = getServiceIdKey(service);
|
||||
if (!actions || !idKey) return;
|
||||
|
||||
const actionLabels = {
|
||||
start: { loading: "Starting", success: "started", error: "starting" },
|
||||
stop: { loading: "Stopping", success: "stopped", error: "stopping" },
|
||||
deploy: {
|
||||
loading: "Deploying",
|
||||
success: "queued for deployment",
|
||||
error: "deploying",
|
||||
},
|
||||
};
|
||||
|
||||
const labels = actionLabels[action];
|
||||
|
||||
toast.promise(
|
||||
(async () => {
|
||||
await actions[action].mutateAsync({
|
||||
[idKey]: service.id,
|
||||
} as any);
|
||||
})(),
|
||||
{
|
||||
loading: `${labels.loading} ${service.name}...`,
|
||||
success: () => {
|
||||
utils.environment.one.invalidate({ environmentId });
|
||||
return `${service.name} ${labels.success} successfully`;
|
||||
},
|
||||
error: (error) =>
|
||||
`Error ${labels.error} ${service.name}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleServiceDelete = async (service: Services) => {
|
||||
const actions = getServiceActions(service);
|
||||
const idKey = getServiceIdKey(service);
|
||||
if (!actions || !idKey) return;
|
||||
|
||||
toast.promise(
|
||||
(async () => {
|
||||
await actions.delete.mutateAsync({
|
||||
[idKey]: service.id,
|
||||
} as any);
|
||||
})(),
|
||||
{
|
||||
loading: `Deleting ${service.name}...`,
|
||||
success: () => {
|
||||
utils.environment.one.invalidate({ environmentId });
|
||||
return `${service.name} deleted successfully`;
|
||||
},
|
||||
error: (error) =>
|
||||
`Error deleting ${service.name}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
},
|
||||
);
|
||||
setServiceToDelete(null);
|
||||
};
|
||||
|
||||
// Get unique servers from services
|
||||
const availableServers = useMemo(() => {
|
||||
if (!applications) return [];
|
||||
@@ -1472,110 +1586,156 @@ const EnvironmentPage = (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="gap-5 pb-10 grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredServices?.map((service) => (
|
||||
<Link
|
||||
key={service.id}
|
||||
href={`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`}
|
||||
className="block"
|
||||
>
|
||||
<Card className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border">
|
||||
{service.serverId && (
|
||||
<div className="absolute -left-1 -top-2">
|
||||
<ServerIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute -right-1 -top-2">
|
||||
<StatusTooltip status={service.status} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -left-3 -bottom-3 size-9 translate-y-1 rounded-full p-0 transition-all duration-200 z-10 bg-background border",
|
||||
selectedServices.includes(service.id)
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 group-hover:translate-y-0 group-hover:opacity-100",
|
||||
)}
|
||||
onClick={(e) =>
|
||||
handleServiceSelect(service.id, e)
|
||||
}
|
||||
<ContextMenu key={service.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<Link
|
||||
href={`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`}
|
||||
className="block"
|
||||
>
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={selectedServices.includes(
|
||||
service.id,
|
||||
)}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex flex-row items-center gap-2 justify-between w-full">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap">
|
||||
{service.name}
|
||||
</span>
|
||||
{service.description && (
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{service.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-sm font-medium text-muted-foreground self-start">
|
||||
{service.type === "postgres" && (
|
||||
<PostgresqlIcon className="h-7 w-7" />
|
||||
)}
|
||||
{service.type === "redis" && (
|
||||
<RedisIcon className="h-7 w-7" />
|
||||
)}
|
||||
{service.type === "mariadb" && (
|
||||
<MariadbIcon className="h-7 w-7" />
|
||||
)}
|
||||
{service.type === "mongo" && (
|
||||
<MongodbIcon className="h-7 w-7" />
|
||||
)}
|
||||
{service.type === "mysql" && (
|
||||
<MysqlIcon className="h-7 w-7" />
|
||||
)}
|
||||
{service.type === "application" &&
|
||||
(service.icon ? (
|
||||
// biome-ignore lint/performance/noImgElement: application icon is data URL
|
||||
<img
|
||||
src={service.icon}
|
||||
alt={service.name}
|
||||
className="size-7 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<GlobeIcon className="h-6 w-6" />
|
||||
))}
|
||||
{service.type === "compose" && (
|
||||
<CircuitBoard className="h-6 w-6" />
|
||||
)}
|
||||
{service.type === "libsql" && (
|
||||
<LibsqlIcon className="h-6 w-6" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter className="mt-auto">
|
||||
<div className="space-y-1 text-sm w-full">
|
||||
{service.serverName && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
||||
<ServerIcon className="size-3" />
|
||||
<span className="truncate">
|
||||
{service.serverName}
|
||||
</span>
|
||||
<Card className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border">
|
||||
{service.serverId && (
|
||||
<div className="absolute -left-1 -top-2">
|
||||
<ServerIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<DateTooltip date={service.createdAt}>
|
||||
Created
|
||||
</DateTooltip>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Link>
|
||||
<div className="absolute -right-1 -top-2">
|
||||
<StatusTooltip status={service.status} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -left-3 -bottom-3 size-9 translate-y-1 rounded-full p-0 transition-all duration-200 z-10 bg-background border",
|
||||
selectedServices.includes(service.id)
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 group-hover:translate-y-0 group-hover:opacity-100",
|
||||
)}
|
||||
onClick={(e) =>
|
||||
handleServiceSelect(service.id, e)
|
||||
}
|
||||
>
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={selectedServices.includes(
|
||||
service.id,
|
||||
)}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex flex-row items-center gap-2 justify-between w-full">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap">
|
||||
{service.name}
|
||||
</span>
|
||||
{service.description && (
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{service.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-sm font-medium text-muted-foreground self-start">
|
||||
{service.type === "postgres" && (
|
||||
<PostgresqlIcon className="h-7 w-7" />
|
||||
)}
|
||||
{service.type === "redis" && (
|
||||
<RedisIcon className="h-7 w-7" />
|
||||
)}
|
||||
{service.type === "mariadb" && (
|
||||
<MariadbIcon className="h-7 w-7" />
|
||||
)}
|
||||
{service.type === "mongo" && (
|
||||
<MongodbIcon className="h-7 w-7" />
|
||||
)}
|
||||
{service.type === "mysql" && (
|
||||
<MysqlIcon className="h-7 w-7" />
|
||||
)}
|
||||
{service.type === "application" &&
|
||||
(service.icon ? (
|
||||
// biome-ignore lint/performance/noImgElement: application icon is data URL
|
||||
<img
|
||||
src={service.icon}
|
||||
alt={service.name}
|
||||
className="size-7 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<GlobeIcon className="h-6 w-6" />
|
||||
))}
|
||||
{service.type === "compose" && (
|
||||
<CircuitBoard className="h-6 w-6" />
|
||||
)}
|
||||
{service.type === "libsql" && (
|
||||
<LibsqlIcon className="h-6 w-6" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter className="mt-auto">
|
||||
<div className="space-y-1 text-sm w-full">
|
||||
{service.serverName && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
||||
<ServerIcon className="size-3" />
|
||||
<span className="truncate">
|
||||
{service.serverName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<DateTooltip date={service.createdAt}>
|
||||
Created
|
||||
</DateTooltip>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Link>
|
||||
</ContextMenuTrigger>
|
||||
{service.type !== "libsql" && (
|
||||
<ContextMenuContent className="w-48">
|
||||
<ContextMenuLabel className="truncate">
|
||||
{service.name}
|
||||
</ContextMenuLabel>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() =>
|
||||
handleServiceAction(service, "start")
|
||||
}
|
||||
>
|
||||
<Play className="size-4" />
|
||||
Start
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onClick={() =>
|
||||
handleServiceAction(service, "deploy")
|
||||
}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Deploy
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="flex items-center gap-2 text-orange-500 focus:text-orange-500"
|
||||
onClick={() =>
|
||||
handleServiceAction(service, "stop")
|
||||
}
|
||||
>
|
||||
<Ban className="size-4" />
|
||||
Stop
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
className="flex items-center gap-2 text-red-500 focus:text-red-500"
|
||||
onClick={() => setServiceToDelete(service)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
)}
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1586,6 +1746,38 @@ const EnvironmentPage = (
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Single Service Delete Dialog */}
|
||||
<Dialog
|
||||
open={!!serviceToDelete}
|
||||
onOpenChange={(open) => !open && setServiceToDelete(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Service</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold">{serviceToDelete?.name}</span>?
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setServiceToDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (serviceToDelete) {
|
||||
handleServiceDelete(serviceToDelete);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -22,6 +22,8 @@ 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";
|
||||
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
|
||||
@@ -60,6 +62,7 @@ type TabState =
|
||||
| "advanced"
|
||||
| "deployments"
|
||||
| "domains"
|
||||
| "containers"
|
||||
| "monitoring"
|
||||
| "volumeBackups";
|
||||
|
||||
@@ -231,6 +234,9 @@ const Service = (
|
||||
Deployments
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{permissions?.service.read && (
|
||||
<TabsTrigger value="containers">Containers</TabsTrigger>
|
||||
)}
|
||||
{permissions?.service.create && (
|
||||
<TabsTrigger value="backups">Backups</TabsTrigger>
|
||||
)}
|
||||
@@ -298,6 +304,18 @@ const Service = (
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
{permissions?.service.read && (
|
||||
<TabsContent value="containers">
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<ShowComposeContainers
|
||||
serverId={data?.serverId || undefined}
|
||||
appName={data?.appName || ""}
|
||||
appType={data?.composeType || "docker-compose"}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{permissions?.monitoring.read && (
|
||||
<TabsContent value="monitoring">
|
||||
<div className="pt-2.5">
|
||||
@@ -406,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>
|
||||
)}
|
||||
|
||||
@@ -303,6 +303,7 @@ const Mariadb = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={mariadbId}
|
||||
type="mariadb"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -307,6 +307,7 @@ const Mongo = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={mongoId}
|
||||
type="mongo"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -284,6 +284,7 @@ const MySql = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={mysqlId}
|
||||
type="mysql"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -292,6 +292,7 @@ const Postgresql = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={postgresId}
|
||||
type="postgres"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -296,6 +296,7 @@ const Redis = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={redisId}
|
||||
type="redis"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -21,7 +21,6 @@ import { mariadbRouter } from "./routers/mariadb";
|
||||
import { mongoRouter } from "./routers/mongo";
|
||||
import { mountRouter } from "./routers/mount";
|
||||
import { mysqlRouter } from "./routers/mysql";
|
||||
import { networkRouter } from "./routers/network";
|
||||
import { notificationRouter } from "./routers/notification";
|
||||
import { organizationRouter } from "./routers/organization";
|
||||
import { patchRouter } from "./routers/patch";
|
||||
@@ -59,7 +58,6 @@ export const appRouter = createTRPCRouter({
|
||||
application: applicationRouter,
|
||||
backup: backupRouter,
|
||||
bitbucket: bitbucketRouter,
|
||||
network: networkRouter,
|
||||
certificates: certificateRouter,
|
||||
cluster: clusterRouter,
|
||||
compose: composeRouter,
|
||||
|
||||
@@ -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,180 @@ 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! Starting deployment on target server...");
|
||||
|
||||
// Auto-deploy on target server
|
||||
const jobData: DeploymentJob = {
|
||||
applicationId: input.applicationId,
|
||||
titleLog: "Transfer deployment",
|
||||
type: "deploy",
|
||||
applicationType: "application",
|
||||
descriptionLog: "Auto-deploy after transfer to new server",
|
||||
server: true,
|
||||
};
|
||||
|
||||
if (IS_CLOUD) {
|
||||
jobData.serverId = input.targetServerId;
|
||||
deploy(jobData).catch(() => {});
|
||||
} else {
|
||||
await myQueue.add("deployments", jobData, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
queue.push("Deployment queued 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));
|
||||
|
||||
// Auto-deploy on target server
|
||||
const jobData: DeploymentJob = {
|
||||
applicationId: input.applicationId,
|
||||
titleLog: "Transfer deployment",
|
||||
type: "deploy",
|
||||
applicationType: "application",
|
||||
descriptionLog: "Auto-deploy after transfer to new server",
|
||||
server: true,
|
||||
};
|
||||
|
||||
if (IS_CLOUD) {
|
||||
jobData.serverId = input.targetServerId;
|
||||
deploy(jobData).catch(() => {});
|
||||
} else {
|
||||
await myQueue.add("deployments", jobData, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,179 @@ 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! Starting deployment on target server...");
|
||||
|
||||
const jobData: DeploymentJob = {
|
||||
composeId: input.composeId,
|
||||
titleLog: "Transfer deployment",
|
||||
type: "deploy",
|
||||
applicationType: "compose",
|
||||
descriptionLog: "Auto-deploy after transfer to new server",
|
||||
server: true,
|
||||
};
|
||||
|
||||
if (IS_CLOUD) {
|
||||
jobData.serverId = input.targetServerId;
|
||||
deploy(jobData).catch(() => {});
|
||||
} else {
|
||||
await myQueue.add("deployments", jobData, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
queue.push("Deployment queued 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));
|
||||
|
||||
// Auto-deploy on target server
|
||||
const jobData: DeploymentJob = {
|
||||
composeId: input.composeId,
|
||||
titleLog: "Transfer deployment",
|
||||
type: "deploy",
|
||||
applicationType: "compose",
|
||||
descriptionLog: "Auto-deploy after transfer to new server",
|
||||
server: true,
|
||||
};
|
||||
|
||||
if (IS_CLOUD) {
|
||||
jobData.serverId = input.targetServerId;
|
||||
deploy(jobData).catch(() => {});
|
||||
} else {
|
||||
await myQueue.add("deployments", jobData, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
containerKill,
|
||||
containerRemove,
|
||||
containerRestart,
|
||||
containerStart,
|
||||
containerStop,
|
||||
findServerById,
|
||||
getConfig,
|
||||
getContainers,
|
||||
@@ -35,24 +38,108 @@ export const dockerRouter = createTRPCRouter({
|
||||
return await getContainers(input.serverId);
|
||||
}),
|
||||
|
||||
restartContainer: withPermission("docker", "read")
|
||||
restartContainer: withPermission("service", "read")
|
||||
.input(
|
||||
z.object({
|
||||
containerId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(containerIdRegex, "Invalid container id."),
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const result = await containerRestart(input.containerId);
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
await containerRestart(input.containerId, input.serverId);
|
||||
await audit(ctx, {
|
||||
action: "start",
|
||||
resourceType: "docker",
|
||||
resourceId: input.containerId,
|
||||
resourceName: input.containerId,
|
||||
});
|
||||
return result;
|
||||
}),
|
||||
|
||||
startContainer: withPermission("service", "read")
|
||||
.input(
|
||||
z.object({
|
||||
containerId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(containerIdRegex, "Invalid container id."),
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
await containerStart(input.containerId, input.serverId);
|
||||
await audit(ctx, {
|
||||
action: "start",
|
||||
resourceType: "docker",
|
||||
resourceId: input.containerId,
|
||||
resourceName: input.containerId,
|
||||
});
|
||||
}),
|
||||
|
||||
stopContainer: withPermission("service", "read")
|
||||
.input(
|
||||
z.object({
|
||||
containerId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(containerIdRegex, "Invalid container id."),
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
await containerStop(input.containerId, input.serverId);
|
||||
await audit(ctx, {
|
||||
action: "stop",
|
||||
resourceType: "docker",
|
||||
resourceId: input.containerId,
|
||||
resourceName: input.containerId,
|
||||
});
|
||||
}),
|
||||
|
||||
killContainer: withPermission("service", "read")
|
||||
.input(
|
||||
z.object({
|
||||
containerId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(containerIdRegex, "Invalid container id."),
|
||||
serverId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
if (server.organizationId !== ctx.session?.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
await containerKill(input.containerId, input.serverId);
|
||||
await audit(ctx, {
|
||||
action: "stop",
|
||||
resourceType: "docker",
|
||||
resourceId: input.containerId,
|
||||
resourceName: input.containerId,
|
||||
});
|
||||
}),
|
||||
|
||||
removeContainer: withPermission("docker", "read")
|
||||
|
||||
@@ -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,125 @@ 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! Starting deployment on target server...");
|
||||
await deployMariadb(input.mariadbId).catch(() => {});
|
||||
queue.push("Deployment started!");
|
||||
} 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));
|
||||
|
||||
await deployMariadb(input.mariadbId).catch(() => {});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,125 @@ 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! Starting deployment on target server...");
|
||||
await deployMongo(input.mongoId).catch(() => {});
|
||||
queue.push("Deployment started!");
|
||||
} 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));
|
||||
|
||||
await deployMongo(input.mongoId).catch(() => {});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,125 @@ 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! Starting deployment on target server...");
|
||||
await deployMySql(input.mysqlId).catch(() => {});
|
||||
queue.push("Deployment started!");
|
||||
} 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));
|
||||
|
||||
await deployMySql(input.mysqlId).catch(() => {});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import {
|
||||
createNetwork,
|
||||
findNetworkById,
|
||||
removeNetwork,
|
||||
updateNetwork,
|
||||
} from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||
import { db } from "@/server/db";
|
||||
import {
|
||||
apiCreateNetwork,
|
||||
apiFindOneNetwork,
|
||||
apiRemoveNetwork,
|
||||
apiUpdateNetwork,
|
||||
network as networkTable,
|
||||
} from "@/server/db/schema";
|
||||
|
||||
export const networkRouter = createTRPCRouter({
|
||||
all: protectedProcedure.query(async ({ ctx }) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(networkTable)
|
||||
.where(eq(networkTable.organizationId, ctx.session.activeOrganizationId))
|
||||
.orderBy(desc(networkTable.createdAt));
|
||||
return rows;
|
||||
}),
|
||||
|
||||
one: protectedProcedure
|
||||
.input(apiFindOneNetwork)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const row = await findNetworkById(input.networkId);
|
||||
if (row.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
return row;
|
||||
}),
|
||||
create: protectedProcedure
|
||||
.input(apiCreateNetwork)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return createNetwork(input, ctx.session.activeOrganizationId);
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.input(apiUpdateNetwork)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const network = await findNetworkById(input.networkId);
|
||||
if (network.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Not authorized to update this network",
|
||||
});
|
||||
}
|
||||
return updateNetwork(input);
|
||||
}),
|
||||
|
||||
remove: protectedProcedure
|
||||
.input(apiRemoveNetwork)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const network = await findNetworkById(input.networkId);
|
||||
if (network.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Not authorized to delete this network",
|
||||
});
|
||||
}
|
||||
return removeNetwork(input.networkId);
|
||||
}),
|
||||
});
|
||||
@@ -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,125 @@ 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! Starting deployment on target server...");
|
||||
await deployPostgres(input.postgresId).catch(() => {});
|
||||
queue.push("Deployment started!");
|
||||
} 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));
|
||||
|
||||
await deployPostgres(input.postgresId).catch(() => {});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,125 @@ 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! Starting deployment on target server...");
|
||||
await deployRedis(input.redisId).catch(() => {});
|
||||
queue.push("Deployment started!");
|
||||
} 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));
|
||||
|
||||
await deployRedis(input.redisId).catch(() => {});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
timestamp,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { nanoid } from "nanoid";
|
||||
import { network } from "./network";
|
||||
import { projects } from "./project";
|
||||
import { server } from "./server";
|
||||
import { ssoProvider } from "./sso";
|
||||
@@ -109,7 +108,6 @@ export const organizationRelations = relations(
|
||||
references: [user.id],
|
||||
}),
|
||||
servers: many(server),
|
||||
networks: many(network),
|
||||
projects: many(projects),
|
||||
members: many(member),
|
||||
ssoProviders: many(ssoProvider),
|
||||
|
||||
@@ -227,7 +227,6 @@ export const applications = pgTable("application", {
|
||||
onDelete: "set null",
|
||||
},
|
||||
),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
});
|
||||
|
||||
export const applicationsRelations = relations(
|
||||
@@ -369,7 +368,6 @@ const createSchema = createInsertSchema(applications, {
|
||||
previewRequireCollaboratorPermissions: z.boolean().optional(),
|
||||
watchPaths: z.array(z.string()).optional().optional(),
|
||||
previewLabels: z.array(z.string()).optional(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
cleanCache: z.boolean().optional(),
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
@@ -536,3 +534,9 @@ export const apiUpdateApplication = createSchema
|
||||
applicationId: z.string().min(1),
|
||||
})
|
||||
.omit({ serverId: true });
|
||||
|
||||
export const apiTransferApplication = z.object({
|
||||
applicationId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -108,7 +108,6 @@ export const compose = pgTable("compose", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
});
|
||||
|
||||
export const composeRelations = relations(compose, ({ one, many }) => ({
|
||||
@@ -241,3 +240,9 @@ export const apiRandomizeCompose = createSchema
|
||||
suffix: z.string().optional(),
|
||||
composeId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiTransferCompose = z.object({
|
||||
composeId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ export * from "./libsql";
|
||||
export * from "./mariadb";
|
||||
export * from "./mongo";
|
||||
export * from "./mount";
|
||||
export * from "./network";
|
||||
export * from "./mysql";
|
||||
export * from "./notification";
|
||||
export * from "./patch";
|
||||
|
||||
@@ -87,7 +87,6 @@ export const mariadb = pgTable("mariadb", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
});
|
||||
|
||||
export const mariadbRelations = relations(mariadb, ({ one, many }) => ({
|
||||
@@ -214,3 +213,9 @@ export const apiRebuildMariadb = createSchema
|
||||
mariadbId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiTransferMariadb = z.object({
|
||||
mariadbId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -91,7 +91,6 @@ export const mongo = pgTable("mongo", {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
replicaSets: boolean("replicaSets").default(false),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
});
|
||||
|
||||
export const mongoRelations = relations(mongo, ({ one, many }) => ({
|
||||
@@ -211,3 +210,9 @@ export const apiRebuildMongo = createSchema
|
||||
mongoId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiTransferMongo = z.object({
|
||||
mongoId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -85,7 +85,6 @@ export const mysql = pgTable("mysql", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
});
|
||||
|
||||
export const mysqlRelations = relations(mysql, ({ one, many }) => ({
|
||||
@@ -211,3 +210,9 @@ export const apiRebuildMysql = createSchema
|
||||
mysqlId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiTransferMysql = z.object({
|
||||
mysqlId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { boolean, jsonb, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
import { server } from "./server";
|
||||
|
||||
/** Docker network driver types */
|
||||
export const networkDriver = pgEnum("networkDriver", [
|
||||
"bridge",
|
||||
"host",
|
||||
"overlay",
|
||||
"macvlan",
|
||||
"none",
|
||||
"ipvlan",
|
||||
]);
|
||||
|
||||
export const network = pgTable("network", {
|
||||
networkId: text("networkId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
driver: networkDriver("driver").notNull().default("bridge"),
|
||||
scope: text("scope"), // e.g. "local", "swarm"
|
||||
internal: boolean("internal").notNull().default(false),
|
||||
attachable: boolean("attachable").notNull().default(false),
|
||||
ingress: boolean("ingress").notNull().default(false),
|
||||
configOnly: boolean("configOnly").notNull().default(false),
|
||||
enableIPv4: boolean("enableIPv4").notNull().default(true),
|
||||
enableIPv6: boolean("enableIPv6").notNull().default(false),
|
||||
ipam: jsonb("ipam")
|
||||
.$type<{
|
||||
driver?: string;
|
||||
config?: Array<{ subnet?: string; gateway?: string; ipRange?: string }>;
|
||||
}>()
|
||||
.default({}),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
});
|
||||
|
||||
export const networkRelations = relations(network, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [network.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
server: one(server, {
|
||||
fields: [network.serverId],
|
||||
references: [server.serverId],
|
||||
}),
|
||||
}));
|
||||
|
||||
const createSchema = createInsertSchema(network, {
|
||||
networkId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
driver: z
|
||||
.enum(["bridge", "host", "overlay", "macvlan", "none", "ipvlan"])
|
||||
.optional(),
|
||||
scope: z.string().optional(),
|
||||
internal: z.boolean().optional(),
|
||||
attachable: z.boolean().optional(),
|
||||
ingress: z.boolean().optional(),
|
||||
configOnly: z.boolean().optional(),
|
||||
enableIPv4: z.boolean().optional(),
|
||||
enableIPv6: z.boolean().optional(),
|
||||
ipam: z
|
||||
.object({
|
||||
driver: z.string().optional(),
|
||||
config: z
|
||||
.array(
|
||||
z.object({
|
||||
subnet: z.string().optional(),
|
||||
gateway: z.string().optional(),
|
||||
ipRange: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
organizationId: z.string().min(1),
|
||||
serverId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export const apiCreateNetwork = createSchema
|
||||
.pick({
|
||||
name: true,
|
||||
driver: true,
|
||||
scope: true,
|
||||
internal: true,
|
||||
attachable: true,
|
||||
ingress: true,
|
||||
configOnly: true,
|
||||
enableIPv4: true,
|
||||
enableIPv6: true,
|
||||
ipam: true,
|
||||
serverId: true,
|
||||
})
|
||||
.partial()
|
||||
.required({ name: true });
|
||||
|
||||
export const apiFindOneNetwork = createSchema
|
||||
.pick({
|
||||
networkId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiRemoveNetwork = createSchema
|
||||
.pick({
|
||||
networkId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiUpdateNetwork = createSchema
|
||||
.pick({
|
||||
networkId: true,
|
||||
name: true,
|
||||
driver: true,
|
||||
scope: true,
|
||||
internal: true,
|
||||
attachable: true,
|
||||
ingress: true,
|
||||
configOnly: true,
|
||||
enableIPv4: true,
|
||||
enableIPv6: true,
|
||||
ipam: true,
|
||||
serverId: true,
|
||||
})
|
||||
.partial()
|
||||
.required({ networkId: true });
|
||||
@@ -85,7 +85,6 @@ export const postgres = pgTable("postgres", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
});
|
||||
|
||||
export const postgresRelations = relations(postgres, ({ one, many }) => ({
|
||||
@@ -205,3 +204,9 @@ export const apiRebuildPostgres = createSchema
|
||||
postgresId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiTransferPostgres = z.object({
|
||||
postgresId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -75,7 +75,6 @@ export const redis = pgTable("redis", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
});
|
||||
|
||||
export const redisRelations = relations(redis, ({ one, many }) => ({
|
||||
@@ -188,3 +187,9 @@ export const apiRebuildRedis = createSchema
|
||||
redisId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiTransferRedis = z.object({
|
||||
redisId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -19,7 +19,6 @@ import { libsql } from "./libsql";
|
||||
import { mariadb } from "./mariadb";
|
||||
import { mongo } from "./mongo";
|
||||
import { mysql } from "./mysql";
|
||||
import { network } from "./network";
|
||||
import { postgres } from "./postgres";
|
||||
import { redis } from "./redis";
|
||||
import { schedules } from "./schedule";
|
||||
@@ -125,7 +124,6 @@ export const serverRelations = relations(server, ({ one, many }) => ({
|
||||
mysql: many(mysql),
|
||||
postgres: many(postgres),
|
||||
certificates: many(certificates),
|
||||
networks: many(network),
|
||||
organization: one(organization, {
|
||||
fields: [server.organizationId],
|
||||
references: [organization.id],
|
||||
|
||||
@@ -28,7 +28,6 @@ export * from "./services/mariadb";
|
||||
export * from "./services/mongo";
|
||||
export * from "./services/mount";
|
||||
export * from "./services/mysql";
|
||||
export * from "./services/network";
|
||||
export * from "./services/notification";
|
||||
export * from "./services/patch";
|
||||
export * from "./services/patch-repo";
|
||||
@@ -48,6 +47,7 @@ export * from "./services/server";
|
||||
export * from "./services/settings";
|
||||
export * from "./services/ssh-key";
|
||||
export * from "./services/user";
|
||||
export * from "./services/transfer";
|
||||
export * from "./services/volume-backups";
|
||||
export * from "./services/web-server-settings";
|
||||
export * from "./setup/config-paths";
|
||||
@@ -132,6 +132,7 @@ export * from "./utils/traefik/redirect";
|
||||
export * from "./utils/traefik/security";
|
||||
export * from "./utils/traefik/types";
|
||||
export * from "./utils/traefik/web-server";
|
||||
export * from "./utils/transfer/index";
|
||||
export * from "./utils/volume-backups/index";
|
||||
export * from "./utils/watch-paths/should-deploy";
|
||||
export * from "./wss/utils";
|
||||
|
||||
@@ -196,7 +196,7 @@ const { handler, api } = betterAuth({
|
||||
where: eq(schema.member.role, "owner"),
|
||||
});
|
||||
|
||||
if (!IS_CLOUD) {
|
||||
if (!IS_CLOUD && !isAdminPresent) {
|
||||
await updateWebServerSettings({
|
||||
serverIp: await getPublicIpWithFallback(),
|
||||
});
|
||||
|
||||
@@ -100,7 +100,6 @@ export const findApplicationById = async (applicationId: string) => {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
|
||||
domains: true,
|
||||
deployments: true,
|
||||
mounts: true,
|
||||
|
||||
@@ -417,21 +417,58 @@ export const getContainerLogs = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const containerRestart = async (containerId: string) => {
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(
|
||||
`docker container restart ${containerId}`,
|
||||
);
|
||||
export const containerRestart = async (
|
||||
containerId: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const command = `docker container restart ${containerId}`;
|
||||
const { stderr } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
|
||||
if (stderr) {
|
||||
console.error(`Error: ${stderr}`);
|
||||
return;
|
||||
}
|
||||
if (stderr) {
|
||||
console.error(`Error: ${stderr}`);
|
||||
throw new Error(stderr);
|
||||
}
|
||||
};
|
||||
|
||||
const config = JSON.parse(stdout);
|
||||
export const containerStart = async (
|
||||
containerId: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const command = `docker container start ${containerId}`;
|
||||
const { stderr } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
|
||||
return config;
|
||||
} catch {}
|
||||
if (stderr) {
|
||||
console.error(`Error: ${stderr}`);
|
||||
throw new Error(stderr);
|
||||
}
|
||||
};
|
||||
|
||||
export const containerStop = async (containerId: string, serverId?: string) => {
|
||||
const command = `docker container stop ${containerId}`;
|
||||
const { stderr } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
|
||||
if (stderr) {
|
||||
console.error(`Error: ${stderr}`);
|
||||
throw new Error(stderr);
|
||||
}
|
||||
};
|
||||
|
||||
export const containerKill = async (containerId: string, serverId?: string) => {
|
||||
const command = `docker container kill ${containerId}`;
|
||||
const { stderr } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
|
||||
if (stderr) {
|
||||
console.error(`Error: ${stderr}`);
|
||||
throw new Error(stderr);
|
||||
}
|
||||
};
|
||||
|
||||
export const containerRemove = async (
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
type apiCreateNetwork,
|
||||
type apiUpdateNetwork,
|
||||
network,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { IS_CLOUD } from "../constants";
|
||||
import { getRemoteDocker } from "../utils/servers/remote-docker";
|
||||
|
||||
export const findNetworkById = async (networkId: string) => {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(network)
|
||||
.where(eq(network.networkId, networkId))
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
|
||||
return row;
|
||||
};
|
||||
|
||||
export const createNetwork = async (
|
||||
input: typeof apiCreateNetwork._type,
|
||||
organizationId: string,
|
||||
) => {
|
||||
if (IS_CLOUD) {
|
||||
if (!input.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Server is required",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const created = await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.insert(network)
|
||||
.values({
|
||||
...input,
|
||||
organizationId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!row) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create network",
|
||||
});
|
||||
}
|
||||
|
||||
const ipam = row.ipam ?? {};
|
||||
const ipamConfig = (ipam.config ?? [])
|
||||
.map((c) => {
|
||||
const entry: Record<string, string> = {};
|
||||
if (c.subnet) entry.Subnet = c.subnet;
|
||||
if (c.gateway) entry.Gateway = c.gateway;
|
||||
if (c.ipRange) entry.IPRange = c.ipRange;
|
||||
return entry;
|
||||
})
|
||||
.filter((e) => Object.keys(e).length > 0);
|
||||
|
||||
const docker = await getRemoteDocker(input.serverId ?? null);
|
||||
await docker.createNetwork({
|
||||
Name: row.name,
|
||||
Driver: row.driver,
|
||||
Internal: row.internal,
|
||||
Attachable: row.attachable,
|
||||
Ingress: row.ingress,
|
||||
EnableIPv6: row.enableIPv6,
|
||||
IPAM: {
|
||||
Driver: ipam.driver ?? "default",
|
||||
Config: ipamConfig.length > 0 ? ipamConfig : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
return created;
|
||||
};
|
||||
|
||||
export const updateNetwork = async (input: typeof apiUpdateNetwork._type) => {
|
||||
const { networkId, ...rest } = input;
|
||||
const [updated] = await db
|
||||
.update(network)
|
||||
.set(rest)
|
||||
.where(eq(network.networkId, networkId))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
export const removeNetwork = async (networkId: string) => {
|
||||
const [deleted] = await db
|
||||
.delete(network)
|
||||
.where(eq(network.networkId, networkId))
|
||||
.returning();
|
||||
|
||||
if (!deleted) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
|
||||
return deleted;
|
||||
};
|
||||
456
packages/server/src/services/transfer.ts
Normal file
456
packages/server/src/services/transfer.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import path from "node:path";
|
||||
import { findMountsByApplicationId } from "./mount";
|
||||
import {
|
||||
compareFileLists,
|
||||
getDirectorySize,
|
||||
getVolumeSize,
|
||||
listComposeVolumes,
|
||||
listVolumesByPrefix,
|
||||
scanDirectory,
|
||||
scanDockerVolume,
|
||||
scanMount,
|
||||
} from "../utils/transfer/scanner";
|
||||
import { runPreflightChecks } from "../utils/transfer/preflight";
|
||||
import {
|
||||
syncDirectory,
|
||||
syncDockerVolume,
|
||||
syncMount,
|
||||
syncTraefikConfig,
|
||||
} from "../utils/transfer/sync";
|
||||
import type {
|
||||
ConflictDecision,
|
||||
MountTransferConfig,
|
||||
ServiceType,
|
||||
TransferOptions,
|
||||
TransferProgress,
|
||||
TransferResult,
|
||||
TransferScanResult,
|
||||
} from "../utils/transfer/types";
|
||||
|
||||
const getServiceBasePath = (
|
||||
serviceType: ServiceType,
|
||||
appName: string,
|
||||
isRemote: boolean,
|
||||
): string => {
|
||||
if (serviceType === "compose") {
|
||||
const { COMPOSE_PATH } = paths(isRemote);
|
||||
return path.join(COMPOSE_PATH, appName);
|
||||
}
|
||||
const { APPLICATIONS_PATH } = paths(isRemote);
|
||||
return path.join(APPLICATIONS_PATH, appName);
|
||||
};
|
||||
|
||||
const hasServiceDirectory = (serviceType: ServiceType): boolean => {
|
||||
return serviceType === "application" || serviceType === "compose";
|
||||
};
|
||||
|
||||
const getAutoDataVolumeName = (
|
||||
serviceType: ServiceType,
|
||||
appName: string,
|
||||
): string | null => {
|
||||
const dbTypes: ServiceType[] = [
|
||||
"postgres",
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"mongo",
|
||||
"redis",
|
||||
];
|
||||
if (dbTypes.includes(serviceType)) {
|
||||
return `${appName}-data`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Discover all Docker volumes for a service.
|
||||
* For compose: uses Docker labels + prefix matching.
|
||||
* For databases: uses the auto {appName}-data convention.
|
||||
* For applications: uses user-defined mounts only.
|
||||
*/
|
||||
const discoverServiceVolumes = async (
|
||||
serverId: string | null,
|
||||
serviceType: ServiceType,
|
||||
appName: string,
|
||||
): Promise<string[]> => {
|
||||
const volumes: Set<string> = new Set();
|
||||
|
||||
if (serviceType === "compose") {
|
||||
// Get volumes by compose project label
|
||||
const labelVolumes = await listComposeVolumes(serverId, appName);
|
||||
for (const v of labelVolumes) {
|
||||
volumes.add(v);
|
||||
}
|
||||
|
||||
// Also try prefix matching (compose uses {projectName}_{volumeName} pattern)
|
||||
const prefixVolumes = await listVolumesByPrefix(serverId, `${appName}_`);
|
||||
for (const v of prefixVolumes) {
|
||||
volumes.add(v);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto data volume for databases
|
||||
const autoVolume = getAutoDataVolumeName(serviceType, appName);
|
||||
if (autoVolume) {
|
||||
volumes.add(autoVolume);
|
||||
}
|
||||
|
||||
return Array.from(volumes);
|
||||
};
|
||||
|
||||
export const scanServiceForTransfer = async (
|
||||
opts: TransferOptions,
|
||||
): Promise<TransferScanResult> => {
|
||||
const { serviceType, appName, sourceServerId, targetServerId } = opts;
|
||||
|
||||
const result: TransferScanResult = {
|
||||
serviceDirectory: { files: [], totalSize: 0 },
|
||||
traefikConfig: { exists: false, hasConflict: false },
|
||||
mounts: [],
|
||||
totalTransferSize: 0,
|
||||
totalFiles: 0,
|
||||
conflicts: [],
|
||||
};
|
||||
|
||||
// 1. Scan service directory (application/compose only)
|
||||
if (hasServiceDirectory(serviceType)) {
|
||||
const sourcePath = getServiceBasePath(
|
||||
serviceType,
|
||||
appName,
|
||||
!!sourceServerId,
|
||||
);
|
||||
const targetPath = getServiceBasePath(serviceType, appName, true);
|
||||
|
||||
const sourceFiles = await scanDirectory(sourceServerId, sourcePath);
|
||||
const targetFiles = await scanDirectory(targetServerId, targetPath);
|
||||
const dirSize = await getDirectorySize(sourceServerId, sourcePath);
|
||||
|
||||
const fileConflicts = compareFileLists(sourceFiles, targetFiles);
|
||||
|
||||
result.serviceDirectory = {
|
||||
files: fileConflicts,
|
||||
totalSize: dirSize || sourceFiles.reduce((sum, f) => sum + f.size, 0),
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Check Traefik config
|
||||
if (serviceType === "application" || serviceType === "compose") {
|
||||
const { DYNAMIC_TRAEFIK_PATH } = paths(!!sourceServerId);
|
||||
const configFile = `${appName}.yml`;
|
||||
const sourceConfigFiles = await scanDirectory(
|
||||
sourceServerId,
|
||||
DYNAMIC_TRAEFIK_PATH,
|
||||
);
|
||||
const hasSourceConfig = sourceConfigFiles.some(
|
||||
(f) => f.path === configFile,
|
||||
);
|
||||
|
||||
if (hasSourceConfig) {
|
||||
result.traefikConfig.exists = true;
|
||||
const { DYNAMIC_TRAEFIK_PATH: targetTraefikPath } = paths(true);
|
||||
const targetConfigFiles = await scanDirectory(
|
||||
targetServerId,
|
||||
targetTraefikPath,
|
||||
);
|
||||
result.traefikConfig.hasConflict = targetConfigFiles.some(
|
||||
(f) => f.path === configFile,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Discover and scan ALL Docker volumes for the service
|
||||
const discoveredVolumes = await discoverServiceVolumes(
|
||||
sourceServerId,
|
||||
serviceType,
|
||||
appName,
|
||||
);
|
||||
|
||||
for (const volumeName of discoveredVolumes) {
|
||||
const sourceFiles = await scanDockerVolume(sourceServerId, volumeName);
|
||||
const targetFiles = await scanDockerVolume(targetServerId, volumeName);
|
||||
const volSize = await getVolumeSize(sourceServerId, volumeName);
|
||||
|
||||
const fileConflicts = compareFileLists(sourceFiles, targetFiles);
|
||||
|
||||
result.mounts.push({
|
||||
mount: {
|
||||
mountId: `docker-${volumeName}`,
|
||||
type: "volume",
|
||||
volumeName,
|
||||
mountPath: "/data",
|
||||
},
|
||||
files: fileConflicts,
|
||||
totalSize: volSize || sourceFiles.reduce((sum, f) => sum + f.size, 0),
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Scan user-defined mounts from Dokploy DB
|
||||
const serviceTypeForMount = serviceType as
|
||||
| "application"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "compose";
|
||||
|
||||
const userMounts = await findMountsByApplicationId(
|
||||
opts.serviceId,
|
||||
serviceTypeForMount,
|
||||
);
|
||||
|
||||
for (const mount of userMounts) {
|
||||
if (mount.type === "file") continue;
|
||||
|
||||
// Skip if already discovered as Docker volume
|
||||
if (
|
||||
mount.type === "volume" &&
|
||||
mount.volumeName &&
|
||||
discoveredVolumes.includes(mount.volumeName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mountConfig: MountTransferConfig = {
|
||||
mountId: mount.mountId,
|
||||
type: mount.type,
|
||||
hostPath: mount.hostPath,
|
||||
volumeName: mount.volumeName,
|
||||
mountPath: mount.mountPath,
|
||||
content: mount.content,
|
||||
filePath: mount.filePath,
|
||||
};
|
||||
|
||||
const sourceFiles = await scanMount(sourceServerId, mountConfig);
|
||||
const targetFiles = await scanMount(targetServerId, mountConfig);
|
||||
|
||||
let mountSize = 0;
|
||||
if (mount.type === "volume" && mount.volumeName) {
|
||||
mountSize = await getVolumeSize(sourceServerId, mount.volumeName);
|
||||
} else if (mount.type === "bind" && mount.hostPath) {
|
||||
mountSize = await getDirectorySize(sourceServerId, mount.hostPath);
|
||||
}
|
||||
|
||||
const fileConflicts = compareFileLists(sourceFiles, targetFiles);
|
||||
|
||||
result.mounts.push({
|
||||
mount: mountConfig,
|
||||
files: fileConflicts,
|
||||
totalSize: mountSize || sourceFiles.reduce((sum, f) => sum + f.size, 0),
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
result.totalTransferSize =
|
||||
result.serviceDirectory.totalSize +
|
||||
result.mounts.reduce((sum, m) => sum + m.totalSize, 0);
|
||||
|
||||
result.totalFiles =
|
||||
result.serviceDirectory.files.length +
|
||||
result.mounts.reduce((sum, m) => sum + m.files.length, 0);
|
||||
|
||||
result.conflicts = [
|
||||
...result.serviceDirectory.files,
|
||||
...result.mounts.flatMap((m) => m.files),
|
||||
].filter((f) => f.status !== "match" && f.status !== "missing_target");
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const executeTransfer = async (
|
||||
opts: TransferOptions,
|
||||
decisions: Record<string, ConflictDecision>,
|
||||
onProgress?: (progress: TransferProgress) => void,
|
||||
): Promise<TransferResult> => {
|
||||
const { serviceType, appName, sourceServerId, targetServerId } = opts;
|
||||
const errors: string[] = [];
|
||||
const processedFiles = 0;
|
||||
const transferredBytes = 0;
|
||||
|
||||
const reportProgress = (
|
||||
phase: TransferProgress["phase"],
|
||||
message?: string,
|
||||
currentFile?: string,
|
||||
) => {
|
||||
onProgress?.({
|
||||
phase,
|
||||
currentFile,
|
||||
processedFiles,
|
||||
totalFiles: 0,
|
||||
transferredBytes,
|
||||
totalBytes: 0,
|
||||
percentage: 0,
|
||||
message,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
// Phase 1: Preflight
|
||||
reportProgress("preparing", "Running preflight checks...");
|
||||
|
||||
// Discover all volumes
|
||||
const discoveredVolumes = await discoverServiceVolumes(
|
||||
sourceServerId,
|
||||
serviceType,
|
||||
appName,
|
||||
);
|
||||
|
||||
// User-defined mounts
|
||||
const mountConfigs: MountTransferConfig[] = [];
|
||||
const serviceTypeForMount = serviceType as
|
||||
| "application"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "compose";
|
||||
|
||||
const userMounts = await findMountsByApplicationId(
|
||||
opts.serviceId,
|
||||
serviceTypeForMount,
|
||||
);
|
||||
|
||||
for (const mount of userMounts) {
|
||||
if (mount.type === "file") continue;
|
||||
if (
|
||||
mount.type === "volume" &&
|
||||
mount.volumeName &&
|
||||
discoveredVolumes.includes(mount.volumeName)
|
||||
) {
|
||||
continue; // Will be handled as discovered volume
|
||||
}
|
||||
mountConfigs.push({
|
||||
mountId: mount.mountId,
|
||||
type: mount.type,
|
||||
hostPath: mount.hostPath,
|
||||
volumeName: mount.volumeName,
|
||||
mountPath: mount.mountPath,
|
||||
content: mount.content,
|
||||
filePath: mount.filePath,
|
||||
});
|
||||
}
|
||||
|
||||
const allVolumeConfigs: MountTransferConfig[] = [
|
||||
...discoveredVolumes.map((v) => ({
|
||||
mountId: `docker-${v}`,
|
||||
type: "volume" as const,
|
||||
volumeName: v,
|
||||
mountPath: "/data",
|
||||
})),
|
||||
...mountConfigs,
|
||||
];
|
||||
|
||||
const targetBasePath = getServiceBasePath(serviceType, appName, true);
|
||||
|
||||
const preflight = await runPreflightChecks(
|
||||
targetServerId,
|
||||
targetBasePath,
|
||||
0,
|
||||
allVolumeConfigs,
|
||||
(msg) => reportProgress("preparing", msg),
|
||||
);
|
||||
|
||||
if (!preflight.passed) {
|
||||
return { success: false, errors: preflight.errors };
|
||||
}
|
||||
|
||||
// Phase 2: Sync service directory
|
||||
if (hasServiceDirectory(serviceType)) {
|
||||
reportProgress("syncing_directory", "Syncing service directory...");
|
||||
|
||||
const sourcePath = getServiceBasePath(
|
||||
serviceType,
|
||||
appName,
|
||||
!!sourceServerId,
|
||||
);
|
||||
|
||||
try {
|
||||
await syncDirectory(
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
sourcePath,
|
||||
targetBasePath,
|
||||
(msg) => reportProgress("syncing_directory", msg),
|
||||
);
|
||||
reportProgress("syncing_directory", "Service directory synced");
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`Failed to sync service directory: ${msg}`);
|
||||
reportProgress("syncing_directory", `Error: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Sync Traefik config
|
||||
if (serviceType === "application" || serviceType === "compose") {
|
||||
reportProgress("syncing_traefik", "Syncing Traefik configuration...");
|
||||
try {
|
||||
await syncTraefikConfig(
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
appName,
|
||||
(msg) => reportProgress("syncing_traefik", msg),
|
||||
);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`Failed to sync Traefik config: ${msg}`);
|
||||
reportProgress("syncing_traefik", `Error: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: Sync all discovered Docker volumes
|
||||
reportProgress("syncing_mounts", "Syncing Docker volumes...");
|
||||
|
||||
for (const volumeName of discoveredVolumes) {
|
||||
reportProgress("syncing_mounts", `Syncing volume: ${volumeName}`);
|
||||
try {
|
||||
await syncDockerVolume(
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
volumeName,
|
||||
(msg) => reportProgress("syncing_mounts", msg),
|
||||
);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`Failed to sync volume ${volumeName}: ${msg}`);
|
||||
reportProgress("syncing_mounts", `Error: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: Sync user-defined mounts (bind mounts, etc.)
|
||||
for (const mountConfig of mountConfigs) {
|
||||
const mountLabel =
|
||||
mountConfig.volumeName || mountConfig.hostPath || mountConfig.mountPath;
|
||||
reportProgress("syncing_mounts", `Syncing: ${mountLabel}`);
|
||||
|
||||
try {
|
||||
await syncMount(
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
mountConfig,
|
||||
decisions,
|
||||
(msg) => reportProgress("syncing_mounts", msg),
|
||||
);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`Failed to sync mount ${mountLabel}: ${msg}`);
|
||||
reportProgress("syncing_mounts", `Error: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
reportProgress(
|
||||
"failed",
|
||||
`Transfer completed with errors: ${errors.join(", ")}`,
|
||||
);
|
||||
return { success: false, errors };
|
||||
}
|
||||
|
||||
reportProgress("completed", "Transfer completed successfully!");
|
||||
return { success: true, errors: [] };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
reportProgress("failed", `Transfer failed: ${message}`);
|
||||
return { success: false, errors: [message] };
|
||||
}
|
||||
};
|
||||
4
packages/server/src/utils/transfer/index.ts
Normal file
4
packages/server/src/utils/transfer/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./types";
|
||||
export * from "./scanner";
|
||||
export * from "./sync";
|
||||
export * from "./preflight";
|
||||
100
packages/server/src/utils/transfer/preflight.ts
Normal file
100
packages/server/src/utils/transfer/preflight.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import type { MountTransferConfig } from "./types";
|
||||
|
||||
const execOnServer = async (
|
||||
serverId: string | null,
|
||||
command: string,
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
if (serverId) {
|
||||
return execAsyncRemote(serverId, command);
|
||||
}
|
||||
return execAsync(command);
|
||||
};
|
||||
|
||||
export const ensureDirectoryExists = async (
|
||||
serverId: string | null,
|
||||
dirPath: string,
|
||||
): Promise<void> => {
|
||||
await execOnServer(serverId, `mkdir -p "${dirPath}"`);
|
||||
};
|
||||
|
||||
export const ensureVolumeExists = async (
|
||||
serverId: string | null,
|
||||
volumeName: string,
|
||||
): Promise<void> => {
|
||||
await execOnServer(
|
||||
serverId,
|
||||
`docker volume inspect ${volumeName} > /dev/null 2>&1 || docker volume create ${volumeName}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const checkDiskSpace = async (
|
||||
serverId: string | null,
|
||||
path: string,
|
||||
): Promise<number> => {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`df -B1 "${path}" | tail -1 | awk '{print $4}'`,
|
||||
);
|
||||
return Number.parseInt(stdout.trim(), 10);
|
||||
};
|
||||
|
||||
export const runPreflightChecks = async (
|
||||
targetServerId: string,
|
||||
targetBasePath: string,
|
||||
requiredBytes: number,
|
||||
mounts: MountTransferConfig[],
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<{ passed: boolean; errors: string[] }> => {
|
||||
const errors: string[] = [];
|
||||
|
||||
onLog?.("Checking disk space on target server...");
|
||||
try {
|
||||
const availableBytes = await checkDiskSpace(targetServerId, "/");
|
||||
if (availableBytes < requiredBytes * 1.2) {
|
||||
errors.push(
|
||||
`Insufficient disk space on target server. Required: ${formatBytes(requiredBytes)}, Available: ${formatBytes(availableBytes)}`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
errors.push("Failed to check disk space on target server");
|
||||
}
|
||||
|
||||
onLog?.("Ensuring target directories exist...");
|
||||
try {
|
||||
await ensureDirectoryExists(targetServerId, targetBasePath);
|
||||
} catch {
|
||||
errors.push(`Failed to create directory: ${targetBasePath}`);
|
||||
}
|
||||
|
||||
for (const mount of mounts) {
|
||||
if (mount.type === "volume" && mount.volumeName) {
|
||||
onLog?.(`Ensuring volume exists: ${mount.volumeName}`);
|
||||
try {
|
||||
await ensureVolumeExists(targetServerId, mount.volumeName);
|
||||
} catch {
|
||||
errors.push(`Failed to create volume: ${mount.volumeName}`);
|
||||
}
|
||||
} else if (mount.type === "bind" && mount.hostPath) {
|
||||
onLog?.(`Ensuring bind mount path exists: ${mount.hostPath}`);
|
||||
try {
|
||||
await ensureDirectoryExists(targetServerId, mount.hostPath);
|
||||
} catch {
|
||||
errors.push(`Failed to create directory: ${mount.hostPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
passed: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
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]}`;
|
||||
};
|
||||
300
packages/server/src/utils/transfer/scanner.ts
Normal file
300
packages/server/src/utils/transfer/scanner.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import type {
|
||||
ConflictStatus,
|
||||
FileConflict,
|
||||
FileInfo,
|
||||
MountTransferConfig,
|
||||
} from "./types";
|
||||
|
||||
const execOnServer = async (
|
||||
serverId: string | null,
|
||||
command: string,
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
if (serverId) {
|
||||
return execAsyncRemote(serverId, command);
|
||||
}
|
||||
return execAsync(command);
|
||||
};
|
||||
|
||||
export const scanDirectory = async (
|
||||
serverId: string | null,
|
||||
dirPath: string,
|
||||
): Promise<FileInfo[]> => {
|
||||
// Check if directory exists first
|
||||
try {
|
||||
const { stdout: exists } = await execOnServer(
|
||||
serverId,
|
||||
`test -d "${dirPath}" && echo "yes" || echo "no"`,
|
||||
);
|
||||
if (exists.trim() !== "yes") {
|
||||
return [];
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use find + stat -c (POSIX-compatible on Linux)
|
||||
// stat -c works on GNU coreutils (Debian, Ubuntu, etc.)
|
||||
const command = `find "${dirPath}" -type f -printf '%p|%s|%T@\\n' 2>/dev/null`;
|
||||
|
||||
try {
|
||||
const { stdout } = await execOnServer(serverId, command);
|
||||
if (!stdout.trim()) return [];
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parts = line.split("|");
|
||||
const filePath = parts[0] || "";
|
||||
const size = parts[1] || "0";
|
||||
const modifiedAt = parts[2] || "0";
|
||||
return {
|
||||
path: filePath.replace(dirPath, "").replace(/^\//, ""),
|
||||
size: Number.parseInt(size, 10),
|
||||
modifiedAt: Math.floor(Number.parseFloat(modifiedAt)),
|
||||
};
|
||||
})
|
||||
.filter((f) => f.path);
|
||||
} catch {
|
||||
// Fallback: try simpler ls-based approach
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`find "${dirPath}" -type f 2>/dev/null`,
|
||||
);
|
||||
if (!stdout.trim()) return [];
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((filePath) => ({
|
||||
path: filePath.replace(dirPath, "").replace(/^\//, ""),
|
||||
size: 0,
|
||||
modifiedAt: 0,
|
||||
}))
|
||||
.filter((f) => f.path);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const scanDockerVolume = async (
|
||||
serverId: string | null,
|
||||
volumeName: string,
|
||||
): Promise<FileInfo[]> => {
|
||||
// First check if volume exists
|
||||
try {
|
||||
const { stdout: exists } = await execOnServer(
|
||||
serverId,
|
||||
`docker volume inspect "${volumeName}" >/dev/null 2>&1 && echo "yes" || echo "no"`,
|
||||
);
|
||||
if (exists.trim() !== "yes") {
|
||||
return [];
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use busybox/alpine stat format (-c '%n|%s|%Y')
|
||||
const command = `docker run --rm -v "${volumeName}":/volume:ro alpine sh -c 'find /volume -type f -exec stat -c "%n|%s|%Y" {} + 2>/dev/null || find /volume -type f 2>/dev/null'`;
|
||||
|
||||
try {
|
||||
const { stdout } = await execOnServer(serverId, command);
|
||||
if (!stdout.trim()) return [];
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parts = line.split("|");
|
||||
if (parts.length >= 3) {
|
||||
return {
|
||||
path: (parts[0] || "").replace(/^\/volume\/?/, ""),
|
||||
size: Number.parseInt(parts[1] || "0", 10),
|
||||
modifiedAt: Number.parseInt(parts[2] || "0", 10),
|
||||
};
|
||||
}
|
||||
// Fallback: just file path
|
||||
return {
|
||||
path: line.replace(/^\/volume\/?/, ""),
|
||||
size: 0,
|
||||
modifiedAt: 0,
|
||||
};
|
||||
})
|
||||
.filter((f) => f.path);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getDirectorySize = async (
|
||||
serverId: string | null,
|
||||
dirPath: string,
|
||||
): Promise<number> => {
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`du -sb "${dirPath}" 2>/dev/null | awk '{print $1}'`,
|
||||
);
|
||||
return Number.parseInt(stdout.trim(), 10) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const getVolumeSize = async (
|
||||
serverId: string | null,
|
||||
volumeName: string,
|
||||
): Promise<number> => {
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`docker run --rm -v "${volumeName}":/volume:ro alpine du -sb /volume 2>/dev/null | awk '{print $1}'`,
|
||||
);
|
||||
return Number.parseInt(stdout.trim(), 10) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* List all Docker volumes belonging to a compose project.
|
||||
* Docker compose automatically labels volumes with com.docker.compose.project
|
||||
*/
|
||||
export const listComposeVolumes = async (
|
||||
serverId: string | null,
|
||||
projectName: string,
|
||||
): Promise<string[]> => {
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`docker volume ls --filter "label=com.docker.compose.project=${projectName}" --format "{{.Name}}" 2>/dev/null`,
|
||||
);
|
||||
if (!stdout.trim()) return [];
|
||||
return stdout.trim().split("\n").filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* List all Docker volumes that match a prefix pattern (appName_*).
|
||||
* Fallback for when compose labels are not available.
|
||||
*/
|
||||
export const listVolumesByPrefix = async (
|
||||
serverId: string | null,
|
||||
prefix: string,
|
||||
): Promise<string[]> => {
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`docker volume ls --format "{{.Name}}" 2>/dev/null | grep "^${prefix}" || true`,
|
||||
);
|
||||
if (!stdout.trim()) return [];
|
||||
return stdout.trim().split("\n").filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const computeFileHash = async (
|
||||
serverId: string | null,
|
||||
filePath: string,
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`md5sum "${filePath}" 2>/dev/null | awk '{print $1}'`,
|
||||
);
|
||||
return stdout.trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export const scanMount = async (
|
||||
serverId: string | null,
|
||||
mount: MountTransferConfig,
|
||||
): Promise<FileInfo[]> => {
|
||||
if (mount.type === "volume" && mount.volumeName) {
|
||||
return scanDockerVolume(serverId, mount.volumeName);
|
||||
}
|
||||
if (mount.type === "bind" && mount.hostPath) {
|
||||
return scanDirectory(serverId, mount.hostPath);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const compareFileLists = (
|
||||
sourceFiles: FileInfo[],
|
||||
targetFiles: FileInfo[],
|
||||
): FileConflict[] => {
|
||||
const targetMap = new Map<string, FileInfo>();
|
||||
for (const f of targetFiles) {
|
||||
targetMap.set(f.path, f);
|
||||
}
|
||||
|
||||
const conflicts: FileConflict[] = [];
|
||||
|
||||
for (const sourceFile of sourceFiles) {
|
||||
const targetFile = targetMap.get(sourceFile.path);
|
||||
|
||||
if (!targetFile) {
|
||||
conflicts.push({
|
||||
path: sourceFile.path,
|
||||
status: "missing_target",
|
||||
sourceFile,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
sourceFile.size === targetFile.size &&
|
||||
sourceFile.modifiedAt === targetFile.modifiedAt
|
||||
) {
|
||||
conflicts.push({
|
||||
path: sourceFile.path,
|
||||
status: "match",
|
||||
sourceFile,
|
||||
targetFile,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Different size or time = conflict
|
||||
let status: ConflictStatus;
|
||||
if (sourceFile.modifiedAt > targetFile.modifiedAt) {
|
||||
status = "newer_source";
|
||||
} else if (targetFile.modifiedAt > sourceFile.modifiedAt) {
|
||||
status = "newer_target";
|
||||
} else {
|
||||
status = "conflict";
|
||||
}
|
||||
|
||||
conflicts.push({
|
||||
path: sourceFile.path,
|
||||
status,
|
||||
sourceFile,
|
||||
targetFile,
|
||||
});
|
||||
}
|
||||
|
||||
// Files only on target
|
||||
for (const targetFile of targetFiles) {
|
||||
if (!sourceFiles.some((sf) => sf.path === targetFile.path)) {
|
||||
conflicts.push({
|
||||
path: targetFile.path,
|
||||
status: "newer_target",
|
||||
sourceFile: { path: targetFile.path, size: 0, modifiedAt: 0 },
|
||||
targetFile,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
};
|
||||
395
packages/server/src/utils/transfer/sync.ts
Normal file
395
packages/server/src/utils/transfer/sync.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { findServerById } from "../../services/server";
|
||||
import { Client } from "ssh2";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import type { ConflictDecision, MountTransferConfig } from "./types";
|
||||
|
||||
const execOnServer = async (
|
||||
serverId: string | null,
|
||||
command: string,
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
if (serverId) {
|
||||
return execAsyncRemote(serverId, command);
|
||||
}
|
||||
return execAsync(command);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a direct SSH connection to a server.
|
||||
* Used for streaming binary data (tar pipes) that can't go through execAsyncRemote.
|
||||
*/
|
||||
const getSSHConnection = async (
|
||||
serverId: string,
|
||||
): Promise<{ conn: Client }> => {
|
||||
const server = await findServerById(serverId);
|
||||
if (!server.sshKeyId) {
|
||||
throw new Error(`No SSH key configured for server ${server.name}`);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new Client();
|
||||
conn
|
||||
.on("ready", () => {
|
||||
resolve({ conn });
|
||||
})
|
||||
.on("error", (err) => {
|
||||
reject(
|
||||
new Error(
|
||||
`SSH connection failed to ${server.name} (${server.ipAddress}): ${err.message}`,
|
||||
),
|
||||
);
|
||||
})
|
||||
.connect({
|
||||
host: server.ipAddress,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
privateKey: server.sshKey?.privateKey,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Pipe a tar stream from source SSH connection to target SSH connection.
|
||||
*/
|
||||
const pipeSSH = (
|
||||
sourceConn: Client,
|
||||
targetConn: Client,
|
||||
sourceCmd: string,
|
||||
targetCmd: string,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
sourceConn.exec(sourceCmd, (err, sourceStream) => {
|
||||
if (err) return reject(new Error(`Source exec failed: ${err.message}`));
|
||||
|
||||
targetConn.exec(targetCmd, (err2, targetStream) => {
|
||||
if (err2)
|
||||
return reject(new Error(`Target exec failed: ${err2.message}`));
|
||||
|
||||
let totalBytes = 0;
|
||||
|
||||
sourceStream.on("data", (chunk: Buffer) => {
|
||||
totalBytes += chunk.length;
|
||||
targetStream.write(chunk);
|
||||
});
|
||||
|
||||
sourceStream.on("end", () => {
|
||||
targetStream.end();
|
||||
});
|
||||
|
||||
targetStream.on("close", () => {
|
||||
onLog?.(
|
||||
`Transferred ${(totalBytes / 1024 / 1024).toFixed(2)} MB`,
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
|
||||
sourceStream.on("error", (e: Error) =>
|
||||
reject(new Error(`Source stream error: ${e.message}`)),
|
||||
);
|
||||
targetStream.on("error", (e: Error) =>
|
||||
reject(new Error(`Target stream error: ${e.message}`)),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Stream data from local tar command into a remote SSH command.
|
||||
*/
|
||||
const pipeLocalToRemote = (
|
||||
targetConn: Client,
|
||||
localCmd: string,
|
||||
localArgs: string[],
|
||||
remoteCmd: string,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const localProcess = spawn(localCmd, localArgs, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
targetConn.exec(remoteCmd, (err, targetStream) => {
|
||||
if (err) {
|
||||
localProcess.kill();
|
||||
return reject(new Error(`Remote exec failed: ${err.message}`));
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
|
||||
localProcess.stdout.on("data", (chunk: Buffer) => {
|
||||
totalBytes += chunk.length;
|
||||
targetStream.write(chunk);
|
||||
});
|
||||
|
||||
localProcess.stdout.on("end", () => {
|
||||
targetStream.end();
|
||||
});
|
||||
|
||||
targetStream.on("close", () => {
|
||||
onLog?.(
|
||||
`Transferred ${(totalBytes / 1024 / 1024).toFixed(2)} MB`,
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
|
||||
localProcess.on("error", (e) => reject(e));
|
||||
targetStream.on("error", (e: Error) => reject(e));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Stream data from a remote SSH command into a local tar command.
|
||||
*/
|
||||
const pipeRemoteToLocal = (
|
||||
sourceConn: Client,
|
||||
remoteCmd: string,
|
||||
localCmd: string,
|
||||
localArgs: string[],
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const localProcess = spawn(localCmd, localArgs, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
sourceConn.exec(remoteCmd, (err, sourceStream) => {
|
||||
if (err) {
|
||||
localProcess.kill();
|
||||
return reject(new Error(`Remote exec failed: ${err.message}`));
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
|
||||
sourceStream.on("data", (chunk: Buffer) => {
|
||||
totalBytes += chunk.length;
|
||||
localProcess.stdin.write(chunk);
|
||||
});
|
||||
|
||||
sourceStream.on("end", () => {
|
||||
localProcess.stdin.end();
|
||||
});
|
||||
|
||||
localProcess.on("close", (code: number) => {
|
||||
onLog?.(
|
||||
`Transferred ${(totalBytes / 1024 / 1024).toFixed(2)} MB`,
|
||||
);
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Local process exited with code ${code}`));
|
||||
});
|
||||
|
||||
sourceStream.on("error", (e: Error) => reject(e));
|
||||
localProcess.on("error", (e) => reject(e));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const syncDirectory = async (
|
||||
sourceServerId: string | null,
|
||||
targetServerId: string,
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
onLog?.(`Syncing directory: ${sourcePath} → ${targetPath}`);
|
||||
|
||||
// Ensure target directory exists
|
||||
await execOnServer(targetServerId, `mkdir -p "${targetPath}"`);
|
||||
|
||||
if (sourceServerId && targetServerId) {
|
||||
// Remote → Remote: pipe tar directly between SSH connections
|
||||
onLog?.("Using direct SSH pipe for remote-to-remote transfer...");
|
||||
const [source, target] = await Promise.all([
|
||||
getSSHConnection(sourceServerId),
|
||||
getSSHConnection(targetServerId),
|
||||
]);
|
||||
try {
|
||||
await pipeSSH(
|
||||
source.conn,
|
||||
target.conn,
|
||||
`tar czf - -C "${sourcePath}" . 2>/dev/null`,
|
||||
`tar xzf - -C "${targetPath}"`,
|
||||
onLog,
|
||||
);
|
||||
} finally {
|
||||
source.conn.end();
|
||||
target.conn.end();
|
||||
}
|
||||
} else if (!sourceServerId && targetServerId) {
|
||||
// Local → Remote
|
||||
onLog?.("Transferring from local to remote...");
|
||||
const { conn } = await getSSHConnection(targetServerId);
|
||||
try {
|
||||
await pipeLocalToRemote(
|
||||
conn,
|
||||
"tar",
|
||||
["czf", "-", "-C", sourcePath, "."],
|
||||
`tar xzf - -C "${targetPath}"`,
|
||||
onLog,
|
||||
);
|
||||
} finally {
|
||||
conn.end();
|
||||
}
|
||||
} else if (sourceServerId && !targetServerId) {
|
||||
// Remote → Local
|
||||
onLog?.("Transferring from remote to local...");
|
||||
await execAsync(`mkdir -p "${targetPath}"`);
|
||||
const { conn } = await getSSHConnection(sourceServerId);
|
||||
try {
|
||||
await pipeRemoteToLocal(
|
||||
conn,
|
||||
`tar czf - -C "${sourcePath}" . 2>/dev/null`,
|
||||
"tar",
|
||||
["xzf", "-", "-C", targetPath],
|
||||
onLog,
|
||||
);
|
||||
} finally {
|
||||
conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
onLog?.(`Directory synced successfully: ${targetPath}`);
|
||||
};
|
||||
|
||||
export const syncDockerVolume = async (
|
||||
sourceServerId: string | null,
|
||||
targetServerId: string,
|
||||
volumeName: string,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
onLog?.(`Syncing Docker volume: ${volumeName}`);
|
||||
|
||||
// Ensure volume exists on target
|
||||
await execOnServer(
|
||||
targetServerId,
|
||||
`docker volume inspect "${volumeName}" > /dev/null 2>&1 || docker volume create "${volumeName}"`,
|
||||
);
|
||||
|
||||
const srcTarCmd = `docker run --rm -v "${volumeName}":/volume:ro alpine tar czf - -C /volume . 2>/dev/null`;
|
||||
const dstTarCmd = `docker run --rm -i -v "${volumeName}":/volume alpine tar xzf - -C /volume`;
|
||||
|
||||
if (sourceServerId && targetServerId) {
|
||||
// Remote → Remote
|
||||
onLog?.("Using direct SSH pipe for volume transfer...");
|
||||
const [source, target] = await Promise.all([
|
||||
getSSHConnection(sourceServerId),
|
||||
getSSHConnection(targetServerId),
|
||||
]);
|
||||
try {
|
||||
await pipeSSH(source.conn, target.conn, srcTarCmd, dstTarCmd, onLog);
|
||||
} finally {
|
||||
source.conn.end();
|
||||
target.conn.end();
|
||||
}
|
||||
} else if (!sourceServerId && targetServerId) {
|
||||
// Local → Remote
|
||||
onLog?.("Transferring volume from local to remote...");
|
||||
const { conn } = await getSSHConnection(targetServerId);
|
||||
try {
|
||||
await pipeLocalToRemote(
|
||||
conn,
|
||||
"docker",
|
||||
[
|
||||
"run", "--rm",
|
||||
"-v", `${volumeName}:/volume:ro`,
|
||||
"alpine", "tar", "czf", "-", "-C", "/volume", ".",
|
||||
],
|
||||
dstTarCmd,
|
||||
onLog,
|
||||
);
|
||||
} finally {
|
||||
conn.end();
|
||||
}
|
||||
} else if (sourceServerId && !targetServerId) {
|
||||
// Remote → Local
|
||||
onLog?.("Transferring volume from remote to local...");
|
||||
const { conn } = await getSSHConnection(sourceServerId);
|
||||
try {
|
||||
await pipeRemoteToLocal(
|
||||
conn,
|
||||
srcTarCmd,
|
||||
"docker",
|
||||
[
|
||||
"run", "--rm", "-i",
|
||||
"-v", `${volumeName}:/volume`,
|
||||
"alpine", "tar", "xzf", "-", "-C", "/volume",
|
||||
],
|
||||
onLog,
|
||||
);
|
||||
} finally {
|
||||
conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
onLog?.(`Volume synced successfully: ${volumeName}`);
|
||||
};
|
||||
|
||||
export const syncMount = async (
|
||||
sourceServerId: string | null,
|
||||
targetServerId: string,
|
||||
mount: MountTransferConfig,
|
||||
_decisions: Record<string, ConflictDecision>,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
if (mount.type === "volume" && mount.volumeName) {
|
||||
await syncDockerVolume(
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
mount.volumeName,
|
||||
onLog,
|
||||
);
|
||||
} else if (mount.type === "bind" && mount.hostPath) {
|
||||
await syncDirectory(
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
mount.hostPath,
|
||||
mount.hostPath,
|
||||
onLog,
|
||||
);
|
||||
} else if (mount.type === "file" && mount.content) {
|
||||
onLog?.("File mount will be recreated from database content during deploy");
|
||||
}
|
||||
};
|
||||
|
||||
export const syncTraefikConfig = async (
|
||||
sourceServerId: string | null,
|
||||
targetServerId: string,
|
||||
appName: string,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
onLog?.(`Syncing Traefik config for: ${appName}`);
|
||||
|
||||
const configPath = "/etc/dokploy/traefik/dynamic";
|
||||
const configFile = `${configPath}/${appName}.yml`;
|
||||
|
||||
let configContent: string;
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
sourceServerId,
|
||||
`cat "${configFile}" 2>/dev/null`,
|
||||
);
|
||||
configContent = stdout;
|
||||
} catch {
|
||||
onLog?.("No Traefik config found on source, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!configContent.trim()) {
|
||||
onLog?.("Empty Traefik config on source, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
await execOnServer(targetServerId, `mkdir -p "${configPath}"`);
|
||||
|
||||
const b64 = Buffer.from(configContent).toString("base64");
|
||||
await execOnServer(
|
||||
targetServerId,
|
||||
`echo "${b64}" | base64 -d > "${configFile}"`,
|
||||
);
|
||||
|
||||
onLog?.("Traefik config synced successfully");
|
||||
};
|
||||
91
packages/server/src/utils/transfer/types.ts
Normal file
91
packages/server/src/utils/transfer/types.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
export type ServiceType =
|
||||
| "application"
|
||||
| "compose"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "redis";
|
||||
|
||||
export interface FileInfo {
|
||||
path: string;
|
||||
size: number;
|
||||
modifiedAt: number;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
export type ConflictStatus =
|
||||
| "missing_target"
|
||||
| "newer_source"
|
||||
| "newer_target"
|
||||
| "conflict"
|
||||
| "match";
|
||||
|
||||
export interface FileConflict {
|
||||
path: string;
|
||||
status: ConflictStatus;
|
||||
sourceFile: FileInfo;
|
||||
targetFile?: FileInfo;
|
||||
}
|
||||
|
||||
export interface MountTransferConfig {
|
||||
mountId: string;
|
||||
type: "bind" | "volume" | "file";
|
||||
hostPath?: string | null;
|
||||
volumeName?: string | null;
|
||||
mountPath: string;
|
||||
content?: string | null;
|
||||
filePath?: string | null;
|
||||
}
|
||||
|
||||
export interface TransferScanResult {
|
||||
serviceDirectory: {
|
||||
files: FileConflict[];
|
||||
totalSize: number;
|
||||
};
|
||||
traefikConfig: {
|
||||
exists: boolean;
|
||||
hasConflict: boolean;
|
||||
};
|
||||
mounts: Array<{
|
||||
mount: MountTransferConfig;
|
||||
files: FileConflict[];
|
||||
totalSize: number;
|
||||
}>;
|
||||
totalTransferSize: number;
|
||||
totalFiles: number;
|
||||
conflicts: FileConflict[];
|
||||
}
|
||||
|
||||
export type ConflictDecision = "skip" | "overwrite";
|
||||
|
||||
export interface TransferProgress {
|
||||
phase:
|
||||
| "preparing"
|
||||
| "syncing_directory"
|
||||
| "syncing_traefik"
|
||||
| "syncing_mounts"
|
||||
| "updating_database"
|
||||
| "completed"
|
||||
| "failed";
|
||||
currentFile?: string;
|
||||
processedFiles: number;
|
||||
totalFiles: number;
|
||||
transferredBytes: number;
|
||||
totalBytes: number;
|
||||
percentage: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface TransferOptions {
|
||||
serviceId: string;
|
||||
serviceType: ServiceType;
|
||||
appName: string;
|
||||
sourceServerId: string | null;
|
||||
targetServerId: string;
|
||||
}
|
||||
|
||||
export interface TransferResult {
|
||||
success: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
@@ -176,6 +176,9 @@ importers:
|
||||
'@radix-ui/react-collapsible':
|
||||
specifier: ^1.1.11
|
||||
version: 1.1.12(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-context-menu':
|
||||
specifier: ^2.2.16
|
||||
version: 2.2.16(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.14
|
||||
version: 1.1.15(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
@@ -2801,6 +2804,19 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-context-menu@2.2.16':
|
||||
resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==}
|
||||
peerDependencies:
|
||||
'@types/react': 18.3.5
|
||||
'@types/react-dom': 18.3.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-context@1.0.0':
|
||||
resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==}
|
||||
peerDependencies:
|
||||
@@ -10633,6 +10649,20 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.5
|
||||
|
||||
'@radix-ui/react-context-menu@2.2.16(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@18.3.5)(react@18.2.0)
|
||||
'@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.5)(react@18.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.5)(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.5
|
||||
'@types/react-dom': 18.3.0
|
||||
|
||||
'@radix-ui/react-context@1.0.0(react@18.2.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
|
||||
Reference in New Issue
Block a user