Compare commits

..

2 Commits

Author SHA1 Message Date
Mauricio Siu
2880fb9748 feat: enhance transfer service with auto-deployment and logging
Refactor the TransferService component to include automatic deployment after successful transfers for various service types (application, compose, postgres, mysql, mariadb, mongo, redis). Implement logging functionality to capture transfer progress and errors, improving user feedback during the transfer process. Update related API routers to support these enhancements, ensuring a seamless transfer and deployment experience.
2026-04-15 12:29:07 -06:00
Mauricio Siu
fcbd226796 feat: implement service transfer functionality
Add a new TransferService component to facilitate the transfer of various services (applications, databases, etc.) between servers. This includes updates to the ShowDatabaseAdvancedSettings component to support serverId, and integration of transfer functionality in the application, compose, mariadb, mongo, mysql, postgres, and redis routers. The transfer process includes scanning for transfer readiness and executing the transfer with appropriate permissions and error handling.
2026-04-13 22:36:22 -06:00
78 changed files with 3541 additions and 10450 deletions

View File

@@ -68,45 +68,3 @@ jobs:
echo "✅ OpenAPI synced to website successfully"
- name: Sync to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo
cd mcp-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to MCP repository successfully"
- name: Sync to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo
cd cli-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to CLI repository successfully"

View File

@@ -1,80 +0,0 @@
name: Sync version to MCP and CLI repos
on:
release:
types: [published]
workflow_dispatch:
jobs:
sync-version:
name: Sync version to external repos
runs-on: ubuntu-latest
steps:
- name: Checkout Dokploy repository
uses: actions/checkout@v4
- name: Get version
id: get_version
run: |
VERSION=$(jq -r .version apps/dokploy/package.json | sed 's/^v//')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Sync version to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /tmp/mcp-repo
# Regenerate tools from latest OpenAPI spec
npm install -g pnpm
pnpm install
pnpm run fetch-openapi
pnpm run generate
# Bump version after install so pnpm install doesn't overwrite it
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Release: ${{ github.event.release.html_url }}" \
--allow-empty
git push
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd /tmp/cli-repo
# Copy latest openapi spec and regenerate commands
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
# Bump version after install so pnpm install doesn't overwrite it
if [ -f package.json ]; then
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
fi
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Release: ${{ github.event.release.html_url }}" \
--allow-empty
git push
echo "CLI repo synced to version ${{ steps.get_version.outputs.version }}"

View File

@@ -666,7 +666,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
<div className="space-y-0.5">
<FormLabel>Custom Entrypoint</FormLabel>
<FormDescription>
Use custom entrypoint for domain
Use custom entrypoint for domina
<br />
"web" and/or "websecure" is used by default.
</FormDescription>

View File

@@ -1,291 +0,0 @@
import { formatDistanceToNow } from "date-fns";
import { ArrowRight, Rocket, Server } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { api } from "@/utils/api";
type DeploymentStatus = "idle" | "running" | "done" | "error";
const statusDotClass: Record<string, string> = {
done: "bg-emerald-500",
running: "bg-amber-500",
error: "bg-red-500",
idle: "bg-muted-foreground/40",
};
function getServiceInfo(d: any) {
const app = d.application;
const comp = d.compose;
const serverName: string =
d.server?.name ?? app?.server?.name ?? comp?.server?.name ?? "Dokploy";
if (app?.environment?.project && app.environment) {
return {
name: app.name as string,
environment: app.environment.name as string,
projectName: app.environment.project.name as string,
serverName,
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
};
}
if (comp?.environment?.project && comp.environment) {
return {
name: comp.name as string,
environment: comp.environment.name as string,
projectName: comp.environment.project.name as string,
serverName,
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
};
}
return null;
}
function StatCard({
label,
value,
delta,
}: {
label: string;
value: string;
delta?: string;
}) {
return (
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</span>
<div className="flex flex-col gap-1">
<span className="text-3xl font-semibold tracking-tight">{value}</span>
{delta && (
<span className="text-xs text-muted-foreground">{delta}</span>
)}
</div>
</div>
);
}
function StatusListCard({
label,
items,
}: {
label: string;
items: { dotClass: string; label: string; count: number }[];
}) {
return (
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col gap-3">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</span>
<ul className="flex flex-col gap-1.5">
{items.map((item) => (
<li key={item.label} className="flex items-center gap-2.5 text-sm">
<span
className={`size-2 rounded-full shrink-0 ${item.dotClass}`}
aria-hidden
/>
<span className="font-semibold tabular-nums w-8">{item.count}</span>
<span className="text-muted-foreground">{item.label}</span>
</li>
))}
</ul>
</div>
);
}
export const ShowHome = () => {
const { data: auth } = api.user.get.useQuery();
const { data: homeStats } = api.project.homeStats.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const canReadDeployments = !!permissions?.deployment.read;
const { data: deployments } = api.deployment.allCentralized.useQuery(
undefined,
{
enabled: canReadDeployments,
refetchInterval: 10000,
},
);
const firstName = auth?.user?.firstName?.trim();
const totals = homeStats ?? {
projects: 0,
environments: 0,
applications: 0,
compose: 0,
databases: 0,
services: 0,
};
const statusBreakdown = homeStats?.status ?? {
running: 0,
error: 0,
idle: 0,
};
const recentDeployments = useMemo(() => {
if (!deployments) return [];
return [...deployments]
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)
.slice(0, 10);
}, [deployments]);
const deployStats = useMemo(() => {
const now = Date.now();
const weekMs = 7 * 24 * 60 * 60 * 1000;
const lastStart = now - weekMs;
const prevStart = now - 2 * weekMs;
const last: NonNullable<typeof deployments> = [];
const prev: NonNullable<typeof deployments> = [];
for (const d of deployments ?? []) {
const t = new Date(d.createdAt).getTime();
if (t >= lastStart) last.push(d);
else if (t >= prevStart) prev.push(d);
}
const lastCount = last.length;
const prevCount = prev.length;
let delta: string | undefined;
if (prevCount > 0) {
const pct = Math.round(((lastCount - prevCount) / prevCount) * 100);
delta = `${pct >= 0 ? "+" : ""}${pct}% vs prev 7d`;
} else if (lastCount > 0) {
delta = "no prior data";
} else {
delta = "no activity yet";
}
return { value: String(lastCount), delta };
}, [deployments]);
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl min-h-[85vh]">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-6 h-full">
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<h1 className="text-3xl font-semibold tracking-tight">
{firstName ? `Welcome back, ${firstName}` : "Welcome back"}
</h1>
<Button asChild variant="secondary" className="w-fit">
<Link href="/dashboard/projects">
Go to projects
<ArrowRight className="size-4" />
</Link>
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Projects"
value={String(totals.projects)}
delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
/>
<StatCard
label="Services"
value={String(totals.services)}
delta={`${totals.applications} apps · ${totals.compose} compose · ${totals.databases} db`}
/>
<StatCard
label="Deploys / 7d"
value={deployStats.value}
delta={deployStats.delta}
/>
<StatusListCard
label="Status"
items={[
{
dotClass: "bg-emerald-500",
label: "running",
count: statusBreakdown.running,
},
{
dotClass: "bg-red-500",
label: "errored",
count: statusBreakdown.error,
},
{
dotClass: "bg-muted-foreground/40",
label: "idle",
count: statusBreakdown.idle,
},
]}
/>
</div>
<div className="rounded-xl border bg-background">
<div className="flex items-center justify-between px-5 py-4 border-b">
<div className="flex items-center gap-2">
<Rocket className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Recent deployments</h2>
</div>
{canReadDeployments && (
<Link
href="/dashboard/deployments"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
view all
</Link>
)}
</div>
{!canReadDeployments ? (
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
<Rocket className="size-8 opacity-40" />
<span>You do not have permission to view deployments.</span>
</div>
) : recentDeployments.length === 0 ? (
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
<Rocket className="size-8 opacity-40" />
<span>No deployments yet.</span>
</div>
) : (
<ul className="divide-y">
{recentDeployments.map((d) => {
const info = getServiceInfo(d);
if (!info) return null;
const status = (d.status ?? "idle") as DeploymentStatus;
return (
<li key={d.deploymentId}>
<Link
href={info.href}
className="flex items-center gap-4 px-5 py-4 hover:bg-muted/40 transition-colors"
>
<span
className={`size-2 rounded-full shrink-0 ${statusDotClass[status] ?? statusDotClass.idle}`}
aria-hidden
/>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm truncate">{info.name}</span>
<span className="text-xs text-muted-foreground truncate">
{info.projectName} · {info.environment}
</span>
</div>
<span className="text-xs text-muted-foreground w-36 hidden lg:flex items-center justify-end gap-1.5 truncate">
<Server className="size-3 shrink-0" />
<span className="truncate">{info.serverName}</span>
</span>
<span className="text-xs text-muted-foreground w-20 text-right hidden sm:inline">
{status}
</span>
<span className="text-xs text-muted-foreground w-24 text-right hidden md:inline">
{formatDistanceToNow(new Date(d.createdAt), {
addSuffix: true,
})}
</span>
<span className="text-xs text-muted-foreground hover:text-foreground transition-colors">
logs
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
</div>
</Card>
</div>
);
};

View File

@@ -166,7 +166,6 @@ export const ShowProjects = () => {
return (
total +
(env.applications?.length || 0) +
(env.libsql?.length || 0) +
(env.mariadb?.length || 0) +
(env.mongo?.length || 0) +
(env.mysql?.length || 0) +
@@ -179,7 +178,6 @@ export const ShowProjects = () => {
return (
total +
(env.applications?.length || 0) +
(env.libsql?.length || 0) +
(env.mariadb?.length || 0) +
(env.mongo?.length || 0) +
(env.mysql?.length || 0) +

View File

@@ -167,7 +167,7 @@ export const SearchCommand = () => {
<CommandGroup heading={"Application"} hidden={true}>
<CommandItem
onSelect={() => {
router.push("/dashboard/home");
router.push("/dashboard/projects");
setOpen(false);
}}
>

View File

@@ -425,7 +425,7 @@ export const WelcomeSubscription = () => {
onClick={() => {
if (stepper.isLast) {
setIsOpen(false);
push("/dashboard/home");
push("/dashboard/projects");
} else {
stepper.next();
}

View File

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

View File

@@ -0,0 +1,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>
);
};

View File

@@ -19,7 +19,6 @@ import {
Forward,
GalleryVerticalEnd,
GitBranch,
House,
Key,
KeyRound,
Loader2,
@@ -149,12 +148,6 @@ type Menu = {
// The `isEnabled` function is called to determine if the item should be displayed
const MENU: Menu = {
home: [
{
isSingle: true,
title: "Home",
url: "/dashboard/home",
icon: House,
},
{
isSingle: true,
title: "Projects",

View File

@@ -80,7 +80,7 @@ export const UserNav = () => {
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
router.push("/dashboard/home");
router.push("/dashboard/projects");
}}
>
Projects

View File

@@ -1,236 +0,0 @@
"use client";
import { Copy, KeyRound, Loader2, Plus, Trash2 } from "lucide-react";
import { type ReactNode, useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
interface Props {
children: ReactNode;
}
export const ScimDialog = ({ children }: Props) => {
const utils = api.useUtils();
const baseURL = useUrl();
const [open, setOpen] = useState(false);
const [newProviderId, setNewProviderId] = useState("");
const [justCreatedToken, setJustCreatedToken] = useState<{
providerId: string;
token: string;
} | null>(null);
const { data: providers = [], isPending } = api.scim.listProviders.useQuery(
undefined,
{ enabled: open },
);
const { mutateAsync: generateToken, isPending: isGenerating } =
api.scim.generateToken.useMutation();
const { mutateAsync: deleteProvider, isPending: isDeleting } =
api.scim.deleteProvider.useMutation();
const scimUrl = `${baseURL || "{baseURL}"}/api/auth/scim/v2`;
const handleGenerate = async () => {
const providerId = newProviderId.trim().toLowerCase();
if (!providerId) return;
try {
const result = await generateToken({ providerId });
setJustCreatedToken({
providerId: result.providerId,
token: result.scimToken,
});
setNewProviderId("");
await utils.scim.listProviders.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to generate SCIM token",
);
}
};
const handleDelete = async (providerId: string) => {
try {
await deleteProvider({ providerId });
toast.success("SCIM provider removed");
await utils.scim.listProviders.invalidate();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to delete SCIM provider",
);
}
};
const handleCopy = async (value: string, label: string) => {
try {
await navigator.clipboard.writeText(value);
toast.success(`${label} copied`);
} catch {
toast.error("Failed to copy");
}
};
const handleOpenChange = (next: boolean) => {
setOpen(next);
if (!next) setJustCreatedToken(null);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<KeyRound className="size-5" />
SCIM provisioning
</DialogTitle>
<DialogDescription>
Automatically provision, update, and deactivate users from your
identity provider (Okta, Entra ID, etc.). Configure the SCIM endpoint
below in your IdP.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="grid gap-1">
<Label className="text-xs font-medium text-muted-foreground">
SCIM 2.0 endpoint URL
</Label>
<div className="flex items-center gap-2">
<p className="flex-1 break-all rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
{scimUrl}
</p>
<Button
variant="outline"
size="icon"
className="size-8 shrink-0"
onClick={() => handleCopy(scimUrl, "Endpoint URL")}
disabled={!baseURL}
>
<Copy className="size-3.5" />
</Button>
</div>
</div>
{justCreatedToken && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3">
<p className="text-sm font-medium">
Bearer token for {justCreatedToken.providerId}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Copy this token now it will not be shown again. Paste it into
your IdP's SCIM configuration.
</p>
<div className="mt-2 flex items-center gap-2">
<p className="flex-1 break-all rounded-md bg-background px-2 py-1.5 font-mono text-xs">
{justCreatedToken.token}
</p>
<Button
variant="outline"
size="icon"
className="size-8 shrink-0"
onClick={() =>
handleCopy(justCreatedToken.token, "Bearer token")
}
>
<Copy className="size-3.5" />
</Button>
</div>
</div>
)}
<div className="space-y-2">
<Label className="text-sm font-medium">
Generate token for a new provider
</Label>
<div className="flex gap-2">
<Input
value={newProviderId}
onChange={(e) => setNewProviderId(e.target.value)}
placeholder="okta, entra, jumpcloud..."
className="font-mono text-sm"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleGenerate();
}
}}
/>
<Button
size="sm"
onClick={handleGenerate}
disabled={!newProviderId.trim() || isGenerating}
>
<Plus className="mr-1 size-4" />
Generate
</Button>
</div>
<p className="text-xs text-muted-foreground">
Choose a unique identifier for this IdP connection (lowercase,
alphanumeric, dashes).
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Existing providers</Label>
{isPending ? (
<div className="flex items-center gap-2 justify-center py-4">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Loading...</span>
</div>
) : providers.length === 0 ? (
<p className="rounded-md border border-dashed bg-muted/30 px-3 py-4 text-center text-sm text-muted-foreground">
No SCIM providers configured yet.
</p>
) : (
<ul className="flex flex-col gap-2">
{providers.map((provider) => (
<li
key={provider.id}
className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2"
>
<span className="flex-1 font-mono text-sm">
{provider.providerId}
</span>
<DialogAction
title="Remove SCIM provider"
description={`Remove "${provider.providerId}"? Existing provisioned users will stay but the IdP will no longer be able to sync.`}
type="destructive"
onClick={() => handleDelete(provider.providerId)}
>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0 text-destructive hover:text-destructive"
disabled={isDeleting}
>
<Trash2 className="size-3.5" />
</Button>
</DialogAction>
</li>
))}
</ul>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -44,7 +44,7 @@ export function SignInWithSSO({ children }: SignInWithSSOProps) {
try {
const { data, error } = await authClient.signIn.sso({
email: values.email,
callbackURL: "/dashboard/home",
callbackURL: "/dashboard/projects",
});
if (error) {
toast.error(error.message ?? "Failed to sign in with SSO");

View File

@@ -2,7 +2,6 @@
import {
Eye,
KeyRound,
Loader2,
LogIn,
Pencil,
@@ -35,7 +34,6 @@ import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
import { RegisterOidcDialog } from "./register-oidc-dialog";
import { RegisterSamlDialog } from "./register-saml-dialog";
import { ScimDialog } from "./scim-dialog";
type ProviderForDetails = {
id: string | null;
@@ -171,22 +169,15 @@ export const SSOSettings = () => {
Users can sign in with their organization&apos;s IdP.
</CardDescription>
</div>
<div className="flex flex-wrap gap-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => setManageOriginsOpen(true)}
>
<Shield className="mr-2 size-4" />
Manage origins
</Button>
<ScimDialog>
<Button variant="outline" size="sm">
<KeyRound className="mr-2 size-4" />
Manage SCIM
</Button>
</ScimDialog>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setManageOriginsOpen(true)}
className="shrink-0"
>
<Shield className="mr-2 size-4" />
Manage origins
</Button>
</div>
{isPending ? (

View File

@@ -116,14 +116,6 @@ export function TagSelector({
<HandleTag />
</div>
</CommandEmpty>
{tags.length === 0 && (
<div className="flex flex-col items-center gap-2 py-4">
<span className="text-sm text-muted-foreground">
No tags created yet.
</span>
<HandleTag />
</div>
)}
<CommandGroup>
{tags.map((tag) => {
const isSelected = selectedTags.includes(tag.id);

View File

@@ -1,11 +0,0 @@
CREATE TABLE "scim_provider" (
"id" text PRIMARY KEY NOT NULL,
"provider_id" text NOT NULL,
"scim_token" text NOT NULL,
"organization_id" text,
CONSTRAINT "scim_provider_provider_id_unique" UNIQUE("provider_id"),
CONSTRAINT "scim_provider_scim_token_unique" UNIQUE("scim_token")
);
--> statement-breakpoint
ALTER TABLE "two_factor" ADD COLUMN "verified" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "scim_provider" ADD CONSTRAINT "scim_provider_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -1163,13 +1163,6 @@
"when": 1775845419261,
"tag": "0165_abnormal_greymalkin",
"breakpoints": true
},
{
"idx": 166,
"version": "7",
"when": 1776576422440,
"tag": "0166_overjoyed_big_bertha",
"breakpoints": true
}
]
}

View File

@@ -46,8 +46,8 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@better-auth/api-key": "1.6.5",
"@better-auth/sso": "1.6.5",
"@better-auth/api-key": "1.5.4",
"@better-auth/sso": "1.5.4",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-json": "^6.0.1",
@@ -101,7 +101,7 @@
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1",
"better-auth": "1.6.5",
"better-auth": "1.5.4",
"bl": "6.0.11",
"boxen": "^7.1.1",
"bullmq": "5.67.3",
@@ -113,7 +113,7 @@
"dockerode": "4.0.2",
"dompurify": "^3.3.3",
"dotenv": "16.4.5",
"drizzle-orm": "0.45.2",
"drizzle-orm": "0.45.1",
"drizzle-zod": "0.8.3",
"fancy-ansi": "^0.1.3",
"input-otp": "^1.4.2",

View File

@@ -53,7 +53,7 @@ export default function Custom404({ statusCode, error }: Props) {
<div className="mt-5 flex flex-col justify-center items-center gap-2 sm:flex-row sm:gap-3">
<Link
href="/dashboard/home"
href="/dashboard/projects"
className={buttonVariants({
variant: "secondary",
className: "flex flex-row gap-2",

View File

@@ -102,7 +102,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -24,7 +24,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -1,53 +0,0 @@
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 { ShowHome } from "@/components/dashboard/home/show-home";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
const Home = () => {
return <ShowHome />;
};
export default Home;
Home.getLayout = (page: ReactElement) => {
return <DashboardLayout>{page}</DashboardLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.settings.isCloud.prefetch();
await helpers.user.get.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
},
};
}

View File

@@ -96,7 +96,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}
@@ -122,7 +122,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -509,14 +509,6 @@ const EnvironmentPage = (
deploy: api.mongo.deploy.useMutation(),
};
const libsqlActions = {
start: api.libsql.start.useMutation(),
stop: api.libsql.stop.useMutation(),
move: api.libsql.move.useMutation(),
delete: api.libsql.remove.useMutation(),
deploy: api.libsql.deploy.useMutation(),
};
const handleBulkStart = async () => {
let success = 0;
setIsBulkActionLoading(true);
@@ -549,9 +541,6 @@ const EnvironmentPage = (
case "mongo":
await mongoActions.start.mutateAsync({ mongoId: serviceId });
break;
case "libsql":
await libsqlActions.start.mutateAsync({ libsqlId: serviceId });
break;
}
success++;
} catch {
@@ -599,9 +588,6 @@ const EnvironmentPage = (
case "mongo":
await mongoActions.stop.mutateAsync({ mongoId: serviceId });
break;
case "libsql":
await libsqlActions.stop.mutateAsync({ libsqlId: serviceId });
break;
}
success++;
} catch {
@@ -678,12 +664,6 @@ const EnvironmentPage = (
targetEnvironmentId: selectedTargetEnvironment,
});
break;
case "libsql":
await libsqlActions.move.mutateAsync({
libsqlId: serviceId,
targetEnvironmentId: selectedTargetEnvironment,
});
break;
}
await utils.environment.one.invalidate({
environmentId,
@@ -753,11 +733,6 @@ const EnvironmentPage = (
mongoId: serviceId,
});
break;
case "libsql":
await libsqlActions.delete.mutateAsync({
libsqlId: serviceId,
});
break;
}
await utils.environment.one.invalidate({
environmentId,
@@ -824,11 +799,6 @@ const EnvironmentPage = (
mongoId: serviceId,
});
break;
case "libsql":
await libsqlActions.deploy.mutateAsync({
libsqlId: serviceId,
});
break;
}
success++;
} catch (error) {
@@ -1886,7 +1856,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

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

View File

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

View File

@@ -343,7 +343,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -303,6 +303,7 @@ const Mariadb = (
<ShowDatabaseAdvancedSettings
id={mariadbId}
type="mariadb"
serverId={data?.serverId}
/>
</div>
</TabsContent>
@@ -372,7 +373,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -307,6 +307,7 @@ const Mongo = (
<ShowDatabaseAdvancedSettings
id={mongoId}
type="mongo"
serverId={data?.serverId}
/>
</div>
</TabsContent>
@@ -376,7 +377,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -284,6 +284,7 @@ const MySql = (
<ShowDatabaseAdvancedSettings
id={mysqlId}
type="mysql"
serverId={data?.serverId}
/>
</div>
</TabsContent>
@@ -353,7 +354,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -292,6 +292,7 @@ const Postgresql = (
<ShowDatabaseAdvancedSettings
id={postgresId}
type="postgres"
serverId={data?.serverId}
/>
</div>
</TabsContent>
@@ -360,7 +361,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -296,6 +296,7 @@ const Redis = (
<ShowDatabaseAdvancedSettings
id={redisId}
type="redis"
serverId={data?.serverId}
/>
</div>
</TabsContent>
@@ -363,7 +364,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -18,7 +18,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -35,7 +35,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -24,7 +24,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -28,7 +28,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -24,7 +24,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -45,7 +45,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -46,7 +46,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -24,7 +24,7 @@ export async function getServerSideProps(
return {
redirect: {
permanent: true,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -82,16 +82,6 @@ export default function Home({ IS_CLOUD }: Props) {
});
if (error) {
const isEmailNotVerified =
error.code === "EMAIL_NOT_VERIFIED" ||
error.message?.toLowerCase().includes("email not verified");
if (isEmailNotVerified) {
const msg =
"Your email is not verified. We've sent a new verification link to your email.";
toast.info(msg);
setError(msg);
return;
}
toast.error(error.message);
setError(error.message || "An error occurred while logging in");
return;
@@ -106,7 +96,7 @@ export default function Home({ IS_CLOUD }: Props) {
}
toast.success("Logged in successfully");
router.push("/dashboard/home");
router.push("/dashboard/projects");
} catch {
toast.error("An error occurred while logging in");
} finally {
@@ -133,7 +123,7 @@ export default function Home({ IS_CLOUD }: Props) {
}
toast.success("Logged in successfully");
router.push("/dashboard/home");
router.push("/dashboard/projects");
} catch {
toast.error("An error occurred while verifying 2FA code");
} finally {
@@ -163,7 +153,7 @@ export default function Home({ IS_CLOUD }: Props) {
}
toast.success("Logged in successfully");
router.push("/dashboard/home");
router.push("/dashboard/projects");
} catch {
toast.error("An error occurred while verifying backup code");
} finally {
@@ -408,7 +398,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
redirect: {
permanent: true,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}
@@ -437,7 +427,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
redirect: {
permanent: true,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -139,7 +139,7 @@ const Invitation = ({
});
toast.success("Account created successfully");
router.push("/dashboard/home");
router.push("/dashboard/projects");
} catch {
toast.error("An error occurred while creating your account");
}

View File

@@ -303,7 +303,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
return {
redirect: {
permanent: true,
destination: "/dashboard/home",
destination: "/dashboard/projects",
},
};
}

View File

@@ -31,7 +31,6 @@ import { projectRouter } from "./routers/project";
import { auditLogRouter } from "./routers/proprietary/audit-log";
import { customRoleRouter } from "./routers/proprietary/custom-role";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { scimRouter } from "./routers/proprietary/scim";
import { ssoRouter } from "./routers/proprietary/sso";
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
import { redirectsRouter } from "./routers/redirects";
@@ -94,7 +93,6 @@ export const appRouter = createTRPCRouter({
organization: organizationRouter,
licenseKey: licenseKeyRouter,
sso: ssoRouter,
scim: scimRouter,
whitelabeling: whitelabelingRouter,
customRole: customRoleRouter,
auditLog: auditLogRouter,

View File

@@ -28,6 +28,8 @@ import {
updateDeploymentStatus,
writeConfig,
writeConfigRemote,
scanServiceForTransfer,
executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -62,6 +64,7 @@ import {
apiSaveGithubProvider,
apiSaveGitlabProvider,
apiSaveGitProvider,
apiTransferApplication,
apiUpdateApplication,
applications,
environments,
@@ -1137,4 +1140,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 };
}),
});

View File

@@ -32,6 +32,8 @@ import {
stopCompose,
updateCompose,
updateDeploymentStatus,
scanServiceForTransfer,
executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -63,6 +65,7 @@ import {
apiRandomizeCompose,
apiRedeployCompose,
apiSaveEnvironmentVariablesCompose,
apiTransferCompose,
apiUpdateCompose,
compose as composeTable,
environments,
@@ -1171,4 +1174,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 };
}),
});

View File

@@ -21,6 +21,8 @@ import {
stopService,
stopServiceRemote,
updateMariadbById,
scanServiceForTransfer,
executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -44,6 +46,7 @@ import {
apiResetMariadb,
apiSaveEnvironmentVariablesMariaDB,
apiSaveExternalPortMariaDB,
apiTransferMariadb,
apiUpdateMariaDB,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
@@ -626,4 +629,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 };
}),
});

View File

@@ -21,6 +21,8 @@ import {
stopService,
stopServiceRemote,
updateMongoById,
scanServiceForTransfer,
executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -43,6 +45,7 @@ import {
apiResetMongo,
apiSaveEnvironmentVariablesMongo,
apiSaveExternalPortMongo,
apiTransferMongo,
apiUpdateMongo,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
@@ -637,4 +640,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 };
}),
});

View File

@@ -21,6 +21,8 @@ import {
stopServiceRemote,
updateMySqlById,
getAccessibleServerIds,
scanServiceForTransfer,
executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -43,6 +45,7 @@ import {
apiResetMysql,
apiSaveEnvironmentVariablesMySql,
apiSaveExternalPortMySql,
apiTransferMysql,
apiUpdateMySql,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
@@ -640,4 +643,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 };
}),
});

View File

@@ -22,6 +22,8 @@ import {
stopServiceRemote,
updatePostgresById,
getAccessibleServerIds,
scanServiceForTransfer,
executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -44,6 +46,7 @@ import {
apiResetPostgres,
apiSaveEnvironmentVariablesPostgres,
apiSaveExternalPortPostgres,
apiTransferPostgres,
apiUpdatePostgres,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
@@ -650,4 +653,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 };
}),
});

View File

@@ -487,148 +487,6 @@ export const projectRouter = createTRPCRouter({
},
),
homeStats: protectedProcedure.query(async ({ ctx }) => {
const isPrivileged = ctx.user.role === "owner" || ctx.user.role === "admin";
let accessedProjects: string[] = [];
let accessedEnvironments: string[] = [];
let accessedServices: string[] = [];
if (!isPrivileged) {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
accessedProjects = member.accessedProjects;
accessedEnvironments = member.accessedEnvironments;
accessedServices = member.accessedServices;
if (accessedProjects.length === 0) {
return {
projects: 0,
environments: 0,
applications: 0,
compose: 0,
databases: 0,
services: 0,
status: { running: 0, error: 0, idle: 0 },
};
}
}
const projectIdFilter = isPrivileged
? eq(projects.organizationId, ctx.session.activeOrganizationId)
: and(
sql`${projects.projectId} IN (${sql.join(
accessedProjects.map((id) => sql`${id}`),
sql`, `,
)})`,
eq(projects.organizationId, ctx.session.activeOrganizationId),
);
const environmentFilter = isPrivileged
? undefined
: accessedEnvironments.length === 0
? sql`false`
: sql`${environments.environmentId} IN (${sql.join(
accessedEnvironments.map((envId) => sql`${envId}`),
sql`, `,
)})`;
const applyFilter = (col: AnyPgColumn) =>
isPrivileged ? undefined : buildServiceFilter(col, accessedServices);
const rows = await db.query.projects.findMany({
where: projectIdFilter,
columns: { projectId: true },
with: {
environments: {
where: environmentFilter,
columns: { environmentId: true },
with: {
applications: {
where: applyFilter(applications.applicationId),
columns: { applicationStatus: true },
},
compose: {
where: applyFilter(compose.composeId),
columns: { composeStatus: true },
},
libsql: {
where: applyFilter(libsql.libsqlId),
columns: { applicationStatus: true },
},
mariadb: {
where: applyFilter(mariadb.mariadbId),
columns: { applicationStatus: true },
},
mongo: {
where: applyFilter(mongo.mongoId),
columns: { applicationStatus: true },
},
mysql: {
where: applyFilter(mysql.mysqlId),
columns: { applicationStatus: true },
},
postgres: {
where: applyFilter(postgres.postgresId),
columns: { applicationStatus: true },
},
redis: {
where: applyFilter(redis.redisId),
columns: { applicationStatus: true },
},
},
},
},
});
let applicationsCount = 0;
let composeCount = 0;
let databasesCount = 0;
let environmentsCount = 0;
const status = { running: 0, error: 0, idle: 0 };
const bump = (s?: string | null) => {
if (s === "done") status.running++;
else if (s === "error") status.error++;
else status.idle++;
};
for (const project of rows) {
for (const env of project.environments) {
environmentsCount++;
applicationsCount += env.applications.length;
composeCount += env.compose.length;
databasesCount +=
env.libsql.length +
env.mariadb.length +
env.mongo.length +
env.mysql.length +
env.postgres.length +
env.redis.length;
for (const a of env.applications) bump(a.applicationStatus);
for (const c of env.compose) bump(c.composeStatus);
for (const s of env.libsql) bump(s.applicationStatus);
for (const s of env.mariadb) bump(s.applicationStatus);
for (const s of env.mongo) bump(s.applicationStatus);
for (const s of env.mysql) bump(s.applicationStatus);
for (const s of env.postgres) bump(s.applicationStatus);
for (const s of env.redis) bump(s.applicationStatus);
}
}
return {
projects: rows.length,
environments: environmentsCount,
applications: applicationsCount,
compose: composeCount,
databases: databasesCount,
services: applicationsCount + composeCount + databasesCount,
status,
};
}),
search: protectedProcedure
.input(
z.object({

View File

@@ -1,78 +0,0 @@
import { db } from "@dokploy/server/db";
import { scimProvider } from "@dokploy/server/db/schema";
import { requestToHeaders } from "@dokploy/server/index";
import { auth } from "@dokploy/server/lib/auth";
import { TRPCError } from "@trpc/server";
import { and, asc, eq } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, enterpriseProcedure } from "@/server/api/trpc";
const providerIdSchema = z
.string()
.min(1)
.max(64)
.regex(
/^[a-z0-9][a-z0-9-]*$/,
"Provider ID must be lowercase alphanumeric with optional dashes",
);
export const scimRouter = createTRPCRouter({
listProviders: enterpriseProcedure.query(async ({ ctx }) => {
const providers = await db.query.scimProvider.findMany({
where: eq(scimProvider.organizationId, ctx.session.activeOrganizationId),
columns: {
id: true,
providerId: true,
organizationId: true,
},
orderBy: [asc(scimProvider.providerId)],
});
return providers;
}),
generateToken: enterpriseProcedure
.input(z.object({ providerId: providerIdSchema }))
.mutation(async ({ ctx, input }) => {
const existing = await db.query.scimProvider.findFirst({
where: eq(scimProvider.providerId, input.providerId),
columns: { id: true, organizationId: true },
});
if (existing) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "A SCIM provider with this ID already exists",
});
}
const result = await auth.generateSCIMToken({
body: {
providerId: input.providerId,
organizationId: ctx.session.activeOrganizationId,
},
headers: requestToHeaders(ctx.req),
});
return { scimToken: result.scimToken, providerId: input.providerId };
}),
deleteProvider: enterpriseProcedure
.input(z.object({ providerId: providerIdSchema }))
.mutation(async ({ ctx, input }) => {
const [deleted] = await db
.delete(scimProvider)
.where(
and(
eq(scimProvider.providerId, input.providerId),
eq(
scimProvider.organizationId,
ctx.session.activeOrganizationId,
),
),
)
.returning({ id: scimProvider.id });
if (!deleted) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SCIM provider not found or you do not have permission to delete it",
});
}
return { success: true };
}),
});

View File

@@ -20,6 +20,8 @@ import {
stopServiceRemote,
updateRedisById,
getAccessibleServerIds,
scanServiceForTransfer,
executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -42,6 +44,7 @@ import {
apiResetRedis,
apiSaveEnvironmentVariablesRedis,
apiSaveExternalPortRedis,
apiTransferRedis,
apiUpdateRedis,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
@@ -623,4 +626,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 };
}),
});

View File

@@ -1,311 +1,299 @@
import { relations } from "drizzle-orm";
import {
pgTable,
text,
timestamp,
boolean,
integer,
index,
uniqueIndex,
boolean,
index,
integer,
pgTable,
text,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
firstName: text("first_name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
twoFactorEnabled: boolean("two_factor_enabled").default(false),
role: text("role"),
banned: boolean("banned").default(false),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
ownerId: text("owner_id"),
allowImpersonation: boolean("allow_impersonation").default(false),
lastName: text("last_name").default(""),
enableEnterpriseFeatures: boolean("enable_enterprise_features"),
isValidEnterpriseLicense: boolean("is_valid_enterprise_license"),
id: text("id").primaryKey(),
firstName: text("first_name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
twoFactorEnabled: boolean("two_factor_enabled").default(false),
role: text("role"),
ownerId: text("owner_id"),
allowImpersonation: boolean("allow_impersonation").default(false),
lastName: text("last_name").default(""),
enableEnterpriseFeatures: boolean("enable_enterprise_features"),
isValidEnterpriseLicense: boolean("is_valid_enterprise_license"),
});
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
activeOrganizationId: text("active_organization_id"),
impersonatedBy: text("impersonated_by"),
},
(table) => [index("session_userId_idx").on(table.userId)],
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
activeOrganizationId: text("active_organization_id"),
},
(table) => [index("session_userId_idx").on(table.userId)],
);
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
);
export const verification = pgTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
export const apikey = pgTable(
"apikey",
{
id: text("id").primaryKey(),
configId: text("config_id").default("default").notNull(),
name: text("name"),
start: text("start"),
referenceId: text("reference_id").notNull(),
prefix: text("prefix"),
key: text("key").notNull(),
refillInterval: integer("refill_interval"),
refillAmount: integer("refill_amount"),
lastRefillAt: timestamp("last_refill_at"),
enabled: boolean("enabled").default(true),
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
rateLimitMax: integer("rate_limit_max").default(10),
requestCount: integer("request_count").default(0),
remaining: integer("remaining"),
lastRequest: timestamp("last_request"),
expiresAt: timestamp("expires_at"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
permissions: text("permissions"),
metadata: text("metadata"),
},
(table) => [
index("apikey_configId_idx").on(table.configId),
index("apikey_referenceId_idx").on(table.referenceId),
index("apikey_key_idx").on(table.key),
],
"apikey",
{
id: text("id").primaryKey(),
configId: text("config_id").default("default").notNull(),
name: text("name"),
start: text("start"),
referenceId: text("reference_id").notNull(),
prefix: text("prefix"),
key: text("key").notNull(),
refillInterval: integer("refill_interval"),
refillAmount: integer("refill_amount"),
lastRefillAt: timestamp("last_refill_at"),
enabled: boolean("enabled").default(true),
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
rateLimitMax: integer("rate_limit_max").default(10),
requestCount: integer("request_count").default(0),
remaining: integer("remaining"),
lastRequest: timestamp("last_request"),
expiresAt: timestamp("expires_at"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
permissions: text("permissions"),
metadata: text("metadata"),
},
(table) => [
index("apikey_configId_idx").on(table.configId),
index("apikey_referenceId_idx").on(table.referenceId),
index("apikey_key_idx").on(table.key),
],
);
export const ssoProvider = pgTable("sso_provider", {
id: text("id").primaryKey(),
issuer: text("issuer").notNull(),
oidcConfig: text("oidc_config"),
samlConfig: text("saml_config"),
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
providerId: text("provider_id").notNull().unique(),
organizationId: text("organization_id"),
domain: text("domain").notNull(),
id: text("id").primaryKey(),
issuer: text("issuer").notNull(),
oidcConfig: text("oidc_config"),
samlConfig: text("saml_config"),
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
providerId: text("provider_id").notNull().unique(),
organizationId: text("organization_id"),
domain: text("domain").notNull(),
});
export const twoFactor = pgTable(
"two_factor",
{
id: text("id").primaryKey(),
secret: text("secret").notNull(),
backupCodes: text("backup_codes").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
verified: boolean("verified").default(true),
},
(table) => [
index("twoFactor_secret_idx").on(table.secret),
index("twoFactor_userId_idx").on(table.userId),
],
"two_factor",
{
id: text("id").primaryKey(),
secret: text("secret").notNull(),
backupCodes: text("backup_codes").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [
index("twoFactor_secret_idx").on(table.secret),
index("twoFactor_userId_idx").on(table.userId),
],
);
export const organization = pgTable(
"organization",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
logo: text("logo"),
createdAt: timestamp("created_at").notNull(),
metadata: text("metadata"),
},
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
"organization",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
logo: text("logo"),
createdAt: timestamp("created_at").notNull(),
metadata: text("metadata"),
},
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
);
export const organizationRole = pgTable(
"organization_role",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
role: text("role").notNull(),
permission: text("permission").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").$onUpdate(
() => /* @__PURE__ */ new Date(),
),
},
(table) => [
index("organizationRole_organizationId_idx").on(table.organizationId),
index("organizationRole_role_idx").on(table.role),
],
"organization_role",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
role: text("role").notNull(),
permission: text("permission").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").$onUpdate(
() => /* @__PURE__ */ new Date(),
),
},
(table) => [
index("organizationRole_organizationId_idx").on(table.organizationId),
index("organizationRole_role_idx").on(table.role),
],
);
export const member = pgTable(
"member",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: text("role").default("member").notNull(),
createdAt: timestamp("created_at").notNull(),
},
(table) => [
index("member_organizationId_idx").on(table.organizationId),
index("member_userId_idx").on(table.userId),
],
"member",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: text("role").default("member").notNull(),
createdAt: timestamp("created_at").notNull(),
},
(table) => [
index("member_organizationId_idx").on(table.organizationId),
index("member_userId_idx").on(table.userId),
],
);
export const invitation = pgTable(
"invitation",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: text("role"),
status: text("status").default("pending").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
inviterId: text("inviter_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [
index("invitation_organizationId_idx").on(table.organizationId),
index("invitation_email_idx").on(table.email),
],
"invitation",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: text("role"),
status: text("status").default("pending").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
inviterId: text("inviter_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [
index("invitation_organizationId_idx").on(table.organizationId),
index("invitation_email_idx").on(table.email),
],
);
export const scimProvider = pgTable("scim_provider", {
id: text("id").primaryKey(),
providerId: text("provider_id").notNull().unique(),
scimToken: text("scim_token").notNull().unique(),
organizationId: text("organization_id"),
});
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
ssoProviders: many(ssoProvider),
twoFactors: many(twoFactor),
members: many(member),
invitations: many(invitation),
sessions: many(session),
accounts: many(account),
ssoProviders: many(ssoProvider),
twoFactors: many(twoFactor),
members: many(member),
invitations: many(invitation),
}));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}));
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
user: one(user, {
fields: [ssoProvider.userId],
references: [user.id],
}),
user: one(user, {
fields: [ssoProvider.userId],
references: [user.id],
}),
}));
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
user: one(user, {
fields: [twoFactor.userId],
references: [user.id],
}),
user: one(user, {
fields: [twoFactor.userId],
references: [user.id],
}),
}));
export const organizationRelations = relations(organization, ({ many }) => ({
organizationRoles: many(organizationRole),
members: many(member),
invitations: many(invitation),
organizationRoles: many(organizationRole),
members: many(member),
invitations: many(invitation),
}));
export const organizationRoleRelations = relations(
organizationRole,
({ one }) => ({
organization: one(organization, {
fields: [organizationRole.organizationId],
references: [organization.id],
}),
}),
organizationRole,
({ one }) => ({
organization: one(organization, {
fields: [organizationRole.organizationId],
references: [organization.id],
}),
}),
);
export const memberRelations = relations(member, ({ one }) => ({
organization: one(organization, {
fields: [member.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [member.userId],
references: [user.id],
}),
organization: one(organization, {
fields: [member.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [member.userId],
references: [user.id],
}),
}));
export const invitationRelations = relations(invitation, ({ one }) => ({
organization: one(organization, {
fields: [invitation.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [invitation.inviterId],
references: [user.id],
}),
organization: one(organization, {
fields: [invitation.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [invitation.inviterId],
references: [user.id],
}),
}));

View File

@@ -37,10 +37,9 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@better-auth/api-key": "1.6.5",
"@better-auth/scim": "^1.6.5",
"@better-auth/sso": "1.6.5",
"@better-auth/utils": "0.4.0",
"@better-auth/api-key": "1.5.4",
"@better-auth/sso": "1.5.4",
"@better-auth/utils": "0.3.1",
"@faker-js/faker": "^8.4.1",
"@octokit/auth-app": "^6.1.3",
"@octokit/rest": "^20.1.2",
@@ -52,14 +51,15 @@
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1",
"better-auth": "1.6.5",
"better-auth": "1.5.4",
"better-call": "2.0.2",
"bl": "6.0.11",
"boxen": "^7.1.1",
"date-fns": "3.6.0",
"dockerode": "4.0.2",
"dotenv": "16.4.5",
"drizzle-dbml-generator": "0.10.0",
"drizzle-orm": "0.45.2",
"drizzle-orm": "0.45.1",
"drizzle-zod": "0.5.1",
"lodash": "4.17.21",
"micromatch": "4.0.8",

View File

@@ -214,7 +214,6 @@ export const twoFactor = pgTable("two_factor", {
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
verified: boolean("verified").notNull().default(true),
});
export const apikey = pgTable("apikey", {

View File

@@ -534,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(),
});

View File

@@ -240,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(),
});

View File

@@ -30,7 +30,6 @@ export * from "./redis";
export * from "./registry";
export * from "./rollbacks";
export * from "./schedule";
export * from "./scim";
export * from "./security";
export * from "./server";
export * from "./session";

View File

@@ -213,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(),
});

View File

@@ -210,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(),
});

View File

@@ -210,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(),
});

View File

@@ -204,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(),
});

View File

@@ -187,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(),
});

View File

@@ -1,22 +0,0 @@
import { relations } from "drizzle-orm";
import { pgTable, text } from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { organization } from "./account";
export const scimProvider = pgTable("scim_provider", {
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
providerId: text("provider_id").notNull().unique(),
scimToken: text("scim_token").notNull().unique(),
organizationId: text("organization_id").references(() => organization.id, {
onDelete: "cascade",
}),
});
export const scimProviderRelations = relations(scimProvider, ({ one }) => ({
organization: one(organization, {
fields: [scimProvider.organizationId],
references: [organization.id],
}),
}));

View File

@@ -1,104 +0,0 @@
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Img,
Link,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
export type TemplateProps = {
userName: string;
verificationUrl: string;
};
export const VerifyEmailTemplate = ({
userName = "User",
verificationUrl = "https://app.dokploy.com/verify",
}: TemplateProps) => {
const previewText = "Verify your email address to get started with Dokploy";
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: {
brand: "#007291",
},
},
},
}}
>
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
<Container className="my-[40px] mx-auto max-w-[520px]">
{/* Header */}
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
<Img
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
width="190"
height="120"
alt="Dokploy"
className="my-0 mx-auto"
/>
</Section>
{/* Body */}
<Section className="bg-white px-[40px] py-[32px]">
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
Verify Your Email
</Heading>
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
Hello {userName}, thank you for signing up for Dokploy. Please
verify your email address to activate your account.
</Text>
{/* CTA Button */}
<Section className="text-center mb-[24px]">
<Button
href={verificationUrl}
className="bg-[#09090b] rounded-lg text-white text-[14px] font-semibold no-underline text-center px-[24px] py-[12px]"
>
Verify Email Address
</Button>
</Section>
<Text className="text-[#a1a1aa] text-[13px] leading-[20px] m-0 text-center mb-[16px]">
If the button above doesn't work, copy and paste the following
link into your browser:
</Text>
<Text className="text-[#71717a] text-[12px] leading-[18px] m-0 text-center break-all">
{verificationUrl}
</Text>
</Section>
{/* Footer */}
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
This is an automated email from{" "}
<Link
href="https://dokploy.com"
className="text-[#71717a] underline"
>
Dokploy Cloud
</Link>
. If you didn't create an account, you can safely ignore this
email.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default VerifyEmailTemplate;

View File

@@ -47,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";
@@ -131,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";

View File

@@ -1,52 +0,0 @@
import { apiKey } from "@better-auth/api-key";
import { scim } from "@better-auth/scim";
import { sso } from "@better-auth/sso";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, organization, twoFactor } from "better-auth/plugins";
import { db } from "../db";
import * as schema from "../db/schema";
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
/**
* Minimal better-auth config used only by `@better-auth/cli` to generate /
* inspect database schemas. Must mirror the plugin set in `auth.ts` so the CLI
* sees every table each plugin expects.
*
* Do NOT import this file from the runtime — use `auth.ts` for that.
*/
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
schema,
}),
user: {
modelName: "user",
fields: {
name: "firstName",
},
additionalFields: {
role: { type: "string", input: false },
ownerId: { type: "string", input: false },
allowImpersonation: { type: "boolean", defaultValue: false },
lastName: { type: "string", required: false, defaultValue: "" },
enableEnterpriseFeatures: { type: "boolean", required: false },
isValidEnterpriseLicense: { type: "boolean", required: false },
},
},
plugins: [
apiKey({ enableMetadata: true, references: "user" }),
sso(),
twoFactor(),
organization({
ac,
roles: { owner: ownerRole, admin: adminRole, member: memberRole },
dynamicAccessControl: {
enabled: true,
maximumRolesPerOrganization: 10,
},
}),
scim(),
admin(),
],
});

View File

@@ -1,6 +1,5 @@
import type { IncomingMessage } from "node:http";
import { apiKey } from "@better-auth/api-key";
import { scim } from "@better-auth/scim";
import { sso } from "@better-auth/sso";
import * as bcrypt from "bcrypt";
import { betterAuth } from "better-auth";
@@ -22,10 +21,7 @@ import {
updateWebServerSettings,
} from "../services/web-server-settings";
import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
import {
sendEmail,
sendVerificationEmail,
} from "../verification/send-verification-email";
import { sendEmail } from "../verification/send-verification-email";
import { getPublicIpWithFallback } from "../wss/utils";
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
@@ -110,13 +106,14 @@ const { handler, api } = betterAuth({
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
sendOnSignIn: true,
sendVerificationEmail: async ({ user, url }) => {
if (IS_CLOUD) {
await sendVerificationEmail({
userName: user.name || "User",
await sendEmail({
email: user.email,
verificationUrl: url,
subject: "Verify your email",
text: `
<p>Click the link to verify your email: <a href="${url}">Verify Email</a></p>
`,
});
}
},
@@ -179,8 +176,7 @@ const { handler, api } = betterAuth({
}
} else {
const isSSORequest = context?.path.includes("/sso");
const isSCIMRequest = context?.path.includes("/scim");
if (isSSORequest || isSCIMRequest) {
if (isSSORequest) {
return;
}
const isAdminPresent = await db.query.member.findFirst({
@@ -196,7 +192,6 @@ const { handler, api } = betterAuth({
},
after: async (user, context) => {
const isSSORequest = context?.path.includes("/sso");
const isSCIMRequest = context?.path.includes("/scim");
const isAdminPresent = await db.query.member.findFirst({
where: eq(schema.member.role, "owner"),
});
@@ -232,10 +227,6 @@ const { handler, api } = betterAuth({
}
}
if (isSCIMRequest) {
return;
}
if (IS_CLOUD || !isAdminPresent) {
await db.transaction(async (tx) => {
const organization = await tx
@@ -403,24 +394,7 @@ const { handler, api } = betterAuth({
enableMetadata: true,
references: "user",
}),
sso({
saml: {
enableInResponseToValidation: false,
},
}),
scim({
beforeSCIMTokenGenerated: async ({ user }) => {
const dbUser = await db.query.user.findFirst({
where: eq(schema.user.id, user.id),
columns: { enableEnterpriseFeatures: true },
});
if (!dbUser?.enableEnterpriseFeatures) {
throw new APIError("FORBIDDEN", {
message: "SCIM provisioning requires an enterprise license",
});
}
},
}),
sso(),
twoFactor(),
organization({
ac,
@@ -466,9 +440,6 @@ const _auth = {
createApiKey: api.createApiKey,
registerSSOProvider: api.registerSSOProvider,
updateSSOProvider: api.updateSSOProvider,
generateSCIMToken: api.generateSCIMToken,
listSCIMProviderConnections: api.listSCIMProviderConnections,
deleteSCIMProviderConnection: api.deleteSCIMProviderConnection,
};
export type AuthType = typeof _auth;

View 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] };
}
};

View File

@@ -0,0 +1,4 @@
export * from "./types";
export * from "./scanner";
export * from "./sync";
export * from "./preflight";

View 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]}`;
};

View 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;
};

View 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");
};

View 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[];
}

View File

@@ -1,7 +1,4 @@
import { renderAsync } from "@react-email/components";
import VerifyEmailTemplate from "../emails/emails/verify-email";
import { sendEmailNotification } from "../utils/notifications/utils";
export const sendEmail = async ({
email,
subject,
@@ -29,25 +26,3 @@ export const sendEmail = async ({
return true;
};
export const sendVerificationEmail = async ({
userName,
email,
verificationUrl,
}: {
userName: string;
email: string;
verificationUrl: string;
}) => {
const html = await renderAsync(
VerifyEmailTemplate({
userName: userName || "User",
verificationUrl,
}),
);
await sendEmail({
email,
subject: "Verify your email",
text: html,
});
};

767
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff