mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-18 13:45:23 +02:00
Compare commits
23 Commits
feat/servi
...
v0.29.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98a586478e | ||
|
|
13248c8d8a | ||
|
|
54417ca8e7 | ||
|
|
b392e58001 | ||
|
|
d9945c0a4f | ||
|
|
f6e2c033ba | ||
|
|
5c787adae1 | ||
|
|
2ba1df1eaa | ||
|
|
e7859395b1 | ||
|
|
6f0ed89ce7 | ||
|
|
4277a509b2 | ||
|
|
f7b576cbf3 | ||
|
|
425fef6e28 | ||
|
|
958372c5f9 | ||
|
|
e7c581476e | ||
|
|
0cae8330e2 | ||
|
|
4a271c11e7 | ||
|
|
fda367b2c5 | ||
|
|
ea1238b1d1 | ||
|
|
b060f80932 | ||
|
|
04b9f56333 | ||
|
|
599b97da51 | ||
|
|
415298fddb |
42
.github/workflows/sync-openapi-docs.yml
vendored
42
.github/workflows/sync-openapi-docs.yml
vendored
@@ -68,3 +68,45 @@ 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"
|
||||
|
||||
|
||||
80
.github/workflows/sync-version.yml
vendored
Normal file
80
.github/workflows/sync-version.yml
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
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 }}"
|
||||
|
||||
@@ -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 domina
|
||||
Use custom entrypoint for domain
|
||||
<br />
|
||||
"web" and/or "websecure" is used by default.
|
||||
</FormDescription>
|
||||
|
||||
291
apps/dokploy/components/dashboard/home/show-home.tsx
Normal file
291
apps/dokploy/components/dashboard/home/show-home.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -166,6 +166,7 @@ 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) +
|
||||
@@ -178,6 +179,7 @@ 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) +
|
||||
|
||||
@@ -167,7 +167,7 @@ export const SearchCommand = () => {
|
||||
<CommandGroup heading={"Application"} hidden={true}>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
router.push("/dashboard/projects");
|
||||
router.push("/dashboard/home");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -425,7 +425,7 @@ export const WelcomeSubscription = () => {
|
||||
onClick={() => {
|
||||
if (stepper.isLast) {
|
||||
setIsOpen(false);
|
||||
push("/dashboard/projects");
|
||||
push("/dashboard/home");
|
||||
} else {
|
||||
stepper.next();
|
||||
}
|
||||
|
||||
@@ -3,15 +3,13 @@ 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, serverId }: Props) => {
|
||||
export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5">
|
||||
<ShowCustomCommand id={id} type={type} />
|
||||
@@ -25,13 +23,6 @@ export const ShowDatabaseAdvancedSettings = ({ id, type, serverId }: 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,596 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Forward,
|
||||
GalleryVerticalEnd,
|
||||
GitBranch,
|
||||
House,
|
||||
Key,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
@@ -148,6 +149,12 @@ 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",
|
||||
|
||||
@@ -80,7 +80,7 @@ export const UserNav = () => {
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
router.push("/dashboard/projects");
|
||||
router.push("/dashboard/home");
|
||||
}}
|
||||
>
|
||||
Projects
|
||||
|
||||
@@ -44,7 +44,7 @@ export function SignInWithSSO({ children }: SignInWithSSOProps) {
|
||||
try {
|
||||
const { data, error } = await authClient.signIn.sso({
|
||||
email: values.email,
|
||||
callbackURL: "/dashboard/projects",
|
||||
callbackURL: "/dashboard/home",
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Failed to sign in with SSO");
|
||||
|
||||
@@ -116,6 +116,14 @@ 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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.29.0",
|
||||
"version": "v0.29.1",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -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/projects"
|
||||
href="/dashboard/home"
|
||||
className={buttonVariants({
|
||||
variant: "secondary",
|
||||
className: "flex flex-row gap-2",
|
||||
|
||||
@@ -102,7 +102,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
53
apps/dokploy/pages/dashboard/home.tsx
Normal file
53
apps/dokploy/pages/dashboard/home.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -122,7 +122,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -509,6 +509,14 @@ 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);
|
||||
@@ -541,6 +549,9 @@ const EnvironmentPage = (
|
||||
case "mongo":
|
||||
await mongoActions.start.mutateAsync({ mongoId: serviceId });
|
||||
break;
|
||||
case "libsql":
|
||||
await libsqlActions.start.mutateAsync({ libsqlId: serviceId });
|
||||
break;
|
||||
}
|
||||
success++;
|
||||
} catch {
|
||||
@@ -588,6 +599,9 @@ const EnvironmentPage = (
|
||||
case "mongo":
|
||||
await mongoActions.stop.mutateAsync({ mongoId: serviceId });
|
||||
break;
|
||||
case "libsql":
|
||||
await libsqlActions.stop.mutateAsync({ libsqlId: serviceId });
|
||||
break;
|
||||
}
|
||||
success++;
|
||||
} catch {
|
||||
@@ -664,6 +678,12 @@ const EnvironmentPage = (
|
||||
targetEnvironmentId: selectedTargetEnvironment,
|
||||
});
|
||||
break;
|
||||
case "libsql":
|
||||
await libsqlActions.move.mutateAsync({
|
||||
libsqlId: serviceId,
|
||||
targetEnvironmentId: selectedTargetEnvironment,
|
||||
});
|
||||
break;
|
||||
}
|
||||
await utils.environment.one.invalidate({
|
||||
environmentId,
|
||||
@@ -733,6 +753,11 @@ const EnvironmentPage = (
|
||||
mongoId: serviceId,
|
||||
});
|
||||
break;
|
||||
case "libsql":
|
||||
await libsqlActions.delete.mutateAsync({
|
||||
libsqlId: serviceId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
await utils.environment.one.invalidate({
|
||||
environmentId,
|
||||
@@ -799,6 +824,11 @@ const EnvironmentPage = (
|
||||
mongoId: serviceId,
|
||||
});
|
||||
break;
|
||||
case "libsql":
|
||||
await libsqlActions.deploy.mutateAsync({
|
||||
libsqlId: serviceId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
success++;
|
||||
} catch (error) {
|
||||
@@ -1856,7 +1886,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ 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";
|
||||
@@ -420,11 +419,6 @@ const Service = (
|
||||
<ShowSecurity applicationId={applicationId} />
|
||||
<ShowPorts applicationId={applicationId} />
|
||||
<ShowTraefikConfig applicationId={applicationId} />
|
||||
<TransferService
|
||||
serviceId={applicationId}
|
||||
serviceType="application"
|
||||
currentServerId={data?.serverId ?? null}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
@@ -496,7 +490,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ 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";
|
||||
@@ -424,11 +423,6 @@ const Service = (
|
||||
<ShowVolumes id={composeId} type="compose" />
|
||||
<ShowImport composeId={composeId} />
|
||||
<IsolatedDeploymentTab composeId={composeId} />
|
||||
<TransferService
|
||||
serviceId={composeId}
|
||||
serviceType="compose"
|
||||
currentServerId={data?.serverId ?? null}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
@@ -498,7 +492,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -343,7 +343,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -303,7 +303,6 @@ const Mariadb = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={mariadbId}
|
||||
type="mariadb"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -373,7 +372,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -307,7 +307,6 @@ const Mongo = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={mongoId}
|
||||
type="mongo"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -377,7 +376,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -284,7 +284,6 @@ const MySql = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={mysqlId}
|
||||
type="mysql"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -354,7 +353,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -292,7 +292,6 @@ const Postgresql = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={postgresId}
|
||||
type="postgres"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -361,7 +360,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -296,7 +296,6 @@ const Redis = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={redisId}
|
||||
type="redis"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -364,7 +363,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,6 +82,16 @@ 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;
|
||||
@@ -96,7 +106,7 @@ export default function Home({ IS_CLOUD }: Props) {
|
||||
}
|
||||
|
||||
toast.success("Logged in successfully");
|
||||
router.push("/dashboard/projects");
|
||||
router.push("/dashboard/home");
|
||||
} catch {
|
||||
toast.error("An error occurred while logging in");
|
||||
} finally {
|
||||
@@ -123,7 +133,7 @@ export default function Home({ IS_CLOUD }: Props) {
|
||||
}
|
||||
|
||||
toast.success("Logged in successfully");
|
||||
router.push("/dashboard/projects");
|
||||
router.push("/dashboard/home");
|
||||
} catch {
|
||||
toast.error("An error occurred while verifying 2FA code");
|
||||
} finally {
|
||||
@@ -153,7 +163,7 @@ export default function Home({ IS_CLOUD }: Props) {
|
||||
}
|
||||
|
||||
toast.success("Logged in successfully");
|
||||
router.push("/dashboard/projects");
|
||||
router.push("/dashboard/home");
|
||||
} catch {
|
||||
toast.error("An error occurred while verifying backup code");
|
||||
} finally {
|
||||
@@ -398,7 +408,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -427,7 +437,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ const Invitation = ({
|
||||
});
|
||||
|
||||
toast.success("Account created successfully");
|
||||
router.push("/dashboard/projects");
|
||||
router.push("/dashboard/home");
|
||||
} catch {
|
||||
toast.error("An error occurred while creating your account");
|
||||
}
|
||||
|
||||
@@ -303,7 +303,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/projects",
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,8 +28,6 @@ import {
|
||||
updateDeploymentStatus,
|
||||
writeConfig,
|
||||
writeConfigRemote,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -64,7 +62,6 @@ import {
|
||||
apiSaveGithubProvider,
|
||||
apiSaveGitlabProvider,
|
||||
apiSaveGitProvider,
|
||||
apiTransferApplication,
|
||||
apiUpdateApplication,
|
||||
applications,
|
||||
environments,
|
||||
@@ -1140,180 +1137,4 @@ export const applicationRouter = createTRPCRouter({
|
||||
application.serverId,
|
||||
);
|
||||
}),
|
||||
|
||||
transferScan: protectedProcedure
|
||||
.input(apiTransferApplication.pick({ applicationId: true, targetServerId: true }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const application = await findApplicationById(input.applicationId);
|
||||
await checkServicePermissionAndAccess(ctx, input.applicationId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
application.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this application",
|
||||
});
|
||||
}
|
||||
|
||||
return await scanServiceForTransfer({
|
||||
serviceId: input.applicationId,
|
||||
serviceType: "application",
|
||||
appName: application.appName,
|
||||
sourceServerId: application.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
});
|
||||
}),
|
||||
|
||||
transferWithLogs: protectedProcedure
|
||||
.input(apiTransferApplication)
|
||||
.subscription(async function* ({ input, ctx, signal }) {
|
||||
const application = await findApplicationById(input.applicationId);
|
||||
await checkServicePermissionAndAccess(ctx, input.applicationId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
application.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this application",
|
||||
});
|
||||
}
|
||||
|
||||
const queue: string[] = [];
|
||||
let done = false;
|
||||
|
||||
executeTransfer(
|
||||
{
|
||||
serviceId: input.applicationId,
|
||||
serviceType: "application",
|
||||
appName: application.appName,
|
||||
sourceServerId: application.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
(progress) => {
|
||||
queue.push(JSON.stringify(progress));
|
||||
},
|
||||
)
|
||||
.then(async (result) => {
|
||||
if (result.success) {
|
||||
await db
|
||||
.update(applications)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(applications.applicationId, input.applicationId));
|
||||
queue.push("Transfer completed! Starting deployment on target server...");
|
||||
|
||||
// Auto-deploy on target server
|
||||
const jobData: DeploymentJob = {
|
||||
applicationId: input.applicationId,
|
||||
titleLog: "Transfer deployment",
|
||||
type: "deploy",
|
||||
applicationType: "application",
|
||||
descriptionLog: "Auto-deploy after transfer to new server",
|
||||
server: true,
|
||||
};
|
||||
|
||||
if (IS_CLOUD) {
|
||||
jobData.serverId = input.targetServerId;
|
||||
deploy(jobData).catch(() => {});
|
||||
} else {
|
||||
await myQueue.add("deployments", jobData, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
queue.push("Deployment queued successfully!");
|
||||
} else {
|
||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
queue.push(
|
||||
`Transfer error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
done = true;
|
||||
});
|
||||
|
||||
while (!done || queue.length > 0) {
|
||||
if (queue.length > 0) {
|
||||
yield queue.shift()!;
|
||||
} else {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
transfer: protectedProcedure
|
||||
.input(apiTransferApplication)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const application = await findApplicationById(input.applicationId);
|
||||
await checkServicePermissionAndAccess(ctx, input.applicationId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
application.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this application",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await executeTransfer(
|
||||
{
|
||||
serviceId: input.applicationId,
|
||||
serviceType: "application",
|
||||
appName: application.appName,
|
||||
sourceServerId: application.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Transfer failed: ${result.errors.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
await db
|
||||
.update(applications)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(applications.applicationId, input.applicationId));
|
||||
|
||||
// Auto-deploy on target server
|
||||
const jobData: DeploymentJob = {
|
||||
applicationId: input.applicationId,
|
||||
titleLog: "Transfer deployment",
|
||||
type: "deploy",
|
||||
applicationType: "application",
|
||||
descriptionLog: "Auto-deploy after transfer to new server",
|
||||
server: true,
|
||||
};
|
||||
|
||||
if (IS_CLOUD) {
|
||||
jobData.serverId = input.targetServerId;
|
||||
deploy(jobData).catch(() => {});
|
||||
} else {
|
||||
await myQueue.add("deployments", jobData, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -32,8 +32,6 @@ import {
|
||||
stopCompose,
|
||||
updateCompose,
|
||||
updateDeploymentStatus,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -65,7 +63,6 @@ import {
|
||||
apiRandomizeCompose,
|
||||
apiRedeployCompose,
|
||||
apiSaveEnvironmentVariablesCompose,
|
||||
apiTransferCompose,
|
||||
apiUpdateCompose,
|
||||
compose as composeTable,
|
||||
environments,
|
||||
@@ -1174,179 +1171,4 @@ 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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -21,8 +21,6 @@ import {
|
||||
stopService,
|
||||
stopServiceRemote,
|
||||
updateMariadbById,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -46,7 +44,6 @@ import {
|
||||
apiResetMariadb,
|
||||
apiSaveEnvironmentVariablesMariaDB,
|
||||
apiSaveExternalPortMariaDB,
|
||||
apiTransferMariadb,
|
||||
apiUpdateMariaDB,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
@@ -629,125 +626,4 @@ export const mariadbRouter = createTRPCRouter({
|
||||
mariadb.serverId,
|
||||
);
|
||||
}),
|
||||
|
||||
transferScan: protectedProcedure
|
||||
.input(apiTransferMariadb.pick({ mariadbId: true, targetServerId: true }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mariadb = await findMariadbById(input.mariadbId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mariadb.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MariaDB",
|
||||
});
|
||||
}
|
||||
return await scanServiceForTransfer({
|
||||
serviceId: input.mariadbId,
|
||||
serviceType: "mariadb",
|
||||
appName: mariadb.appName,
|
||||
sourceServerId: mariadb.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
});
|
||||
}),
|
||||
|
||||
transferWithLogs: protectedProcedure
|
||||
.input(apiTransferMariadb)
|
||||
.subscription(async function* ({ input, ctx, signal }) {
|
||||
const mariadb = await findMariadbById(input.mariadbId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mariadb.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MariaDB",
|
||||
});
|
||||
}
|
||||
const queue: string[] = [];
|
||||
let done = false;
|
||||
executeTransfer(
|
||||
{
|
||||
serviceId: input.mariadbId,
|
||||
serviceType: "mariadb",
|
||||
appName: mariadb.appName,
|
||||
sourceServerId: mariadb.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
(progress) => { queue.push(JSON.stringify(progress)); },
|
||||
)
|
||||
.then(async (result) => {
|
||||
if (result.success) {
|
||||
await db
|
||||
.update(mariadbTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(mariadbTable.mariadbId, input.mariadbId));
|
||||
queue.push("Transfer completed! Starting deployment on target server...");
|
||||
await deployMariadb(input.mariadbId).catch(() => {});
|
||||
queue.push("Deployment started!");
|
||||
} else {
|
||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
})
|
||||
.finally(() => { done = true; });
|
||||
|
||||
while (!done || queue.length > 0) {
|
||||
if (queue.length > 0) { yield queue.shift()!; }
|
||||
else { await new Promise((r) => setTimeout(r, 50)); }
|
||||
if (signal?.aborted) { return; }
|
||||
}
|
||||
}),
|
||||
|
||||
transfer: protectedProcedure
|
||||
.input(apiTransferMariadb)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mariadb = await findMariadbById(input.mariadbId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mariadb.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MariaDB",
|
||||
});
|
||||
}
|
||||
const result = await executeTransfer(
|
||||
{
|
||||
serviceId: input.mariadbId,
|
||||
serviceType: "mariadb",
|
||||
appName: mariadb.appName,
|
||||
sourceServerId: mariadb.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Transfer failed: ${result.errors.join(", ")}`,
|
||||
});
|
||||
}
|
||||
await db
|
||||
.update(mariadbTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(mariadbTable.mariadbId, input.mariadbId));
|
||||
|
||||
await deployMariadb(input.mariadbId).catch(() => {});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -21,8 +21,6 @@ import {
|
||||
stopService,
|
||||
stopServiceRemote,
|
||||
updateMongoById,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -45,7 +43,6 @@ import {
|
||||
apiResetMongo,
|
||||
apiSaveEnvironmentVariablesMongo,
|
||||
apiSaveExternalPortMongo,
|
||||
apiTransferMongo,
|
||||
apiUpdateMongo,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
@@ -640,125 +637,4 @@ export const mongoRouter = createTRPCRouter({
|
||||
mongo.serverId,
|
||||
);
|
||||
}),
|
||||
|
||||
transferScan: protectedProcedure
|
||||
.input(apiTransferMongo.pick({ mongoId: true, targetServerId: true }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mongo = await findMongoById(input.mongoId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mongoId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mongo.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MongoDB",
|
||||
});
|
||||
}
|
||||
return await scanServiceForTransfer({
|
||||
serviceId: input.mongoId,
|
||||
serviceType: "mongo",
|
||||
appName: mongo.appName,
|
||||
sourceServerId: mongo.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
});
|
||||
}),
|
||||
|
||||
transferWithLogs: protectedProcedure
|
||||
.input(apiTransferMongo)
|
||||
.subscription(async function* ({ input, ctx, signal }) {
|
||||
const mongo = await findMongoById(input.mongoId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mongoId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mongo.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MongoDB",
|
||||
});
|
||||
}
|
||||
const queue: string[] = [];
|
||||
let done = false;
|
||||
executeTransfer(
|
||||
{
|
||||
serviceId: input.mongoId,
|
||||
serviceType: "mongo",
|
||||
appName: mongo.appName,
|
||||
sourceServerId: mongo.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
(progress) => { queue.push(JSON.stringify(progress)); },
|
||||
)
|
||||
.then(async (result) => {
|
||||
if (result.success) {
|
||||
await db
|
||||
.update(mongoTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(mongoTable.mongoId, input.mongoId));
|
||||
queue.push("Transfer completed! Starting deployment on target server...");
|
||||
await deployMongo(input.mongoId).catch(() => {});
|
||||
queue.push("Deployment started!");
|
||||
} else {
|
||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
})
|
||||
.finally(() => { done = true; });
|
||||
|
||||
while (!done || queue.length > 0) {
|
||||
if (queue.length > 0) { yield queue.shift()!; }
|
||||
else { await new Promise((r) => setTimeout(r, 50)); }
|
||||
if (signal?.aborted) { return; }
|
||||
}
|
||||
}),
|
||||
|
||||
transfer: protectedProcedure
|
||||
.input(apiTransferMongo)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mongo = await findMongoById(input.mongoId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mongoId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mongo.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MongoDB",
|
||||
});
|
||||
}
|
||||
const result = await executeTransfer(
|
||||
{
|
||||
serviceId: input.mongoId,
|
||||
serviceType: "mongo",
|
||||
appName: mongo.appName,
|
||||
sourceServerId: mongo.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Transfer failed: ${result.errors.join(", ")}`,
|
||||
});
|
||||
}
|
||||
await db
|
||||
.update(mongoTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(mongoTable.mongoId, input.mongoId));
|
||||
|
||||
await deployMongo(input.mongoId).catch(() => {});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -21,8 +21,6 @@ import {
|
||||
stopServiceRemote,
|
||||
updateMySqlById,
|
||||
getAccessibleServerIds,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -45,7 +43,6 @@ import {
|
||||
apiResetMysql,
|
||||
apiSaveEnvironmentVariablesMySql,
|
||||
apiSaveExternalPortMySql,
|
||||
apiTransferMysql,
|
||||
apiUpdateMySql,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
@@ -643,125 +640,4 @@ 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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -22,8 +22,6 @@ import {
|
||||
stopServiceRemote,
|
||||
updatePostgresById,
|
||||
getAccessibleServerIds,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -46,7 +44,6 @@ import {
|
||||
apiResetPostgres,
|
||||
apiSaveEnvironmentVariablesPostgres,
|
||||
apiSaveExternalPortPostgres,
|
||||
apiTransferPostgres,
|
||||
apiUpdatePostgres,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
@@ -653,125 +650,4 @@ 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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -487,6 +487,148 @@ 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({
|
||||
|
||||
@@ -20,8 +20,6 @@ import {
|
||||
stopServiceRemote,
|
||||
updateRedisById,
|
||||
getAccessibleServerIds,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -44,7 +42,6 @@ import {
|
||||
apiResetRedis,
|
||||
apiSaveEnvironmentVariablesRedis,
|
||||
apiSaveExternalPortRedis,
|
||||
apiTransferRedis,
|
||||
apiUpdateRedis,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
@@ -626,125 +623,4 @@ 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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -534,9 +534,3 @@ 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(),
|
||||
});
|
||||
|
||||
@@ -240,9 +240,3 @@ 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(),
|
||||
});
|
||||
|
||||
@@ -213,9 +213,3 @@ 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(),
|
||||
});
|
||||
|
||||
@@ -210,9 +210,3 @@ 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(),
|
||||
});
|
||||
|
||||
@@ -210,9 +210,3 @@ 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(),
|
||||
});
|
||||
|
||||
@@ -204,9 +204,3 @@ 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(),
|
||||
});
|
||||
|
||||
@@ -187,9 +187,3 @@ 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(),
|
||||
});
|
||||
|
||||
104
packages/server/src/emails/emails/verify-email.tsx
Normal file
104
packages/server/src/emails/emails/verify-email.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
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;
|
||||
@@ -47,7 +47,6 @@ export * from "./services/server";
|
||||
export * from "./services/settings";
|
||||
export * from "./services/ssh-key";
|
||||
export * from "./services/user";
|
||||
export * from "./services/transfer";
|
||||
export * from "./services/volume-backups";
|
||||
export * from "./services/web-server-settings";
|
||||
export * from "./setup/config-paths";
|
||||
@@ -132,7 +131,6 @@ 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";
|
||||
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
updateWebServerSettings,
|
||||
} from "../services/web-server-settings";
|
||||
import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
|
||||
import { sendEmail } from "../verification/send-verification-email";
|
||||
import {
|
||||
sendEmail,
|
||||
sendVerificationEmail,
|
||||
} from "../verification/send-verification-email";
|
||||
import { getPublicIpWithFallback } from "../wss/utils";
|
||||
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
|
||||
|
||||
@@ -106,14 +109,13 @@ const { handler, api } = betterAuth({
|
||||
emailVerification: {
|
||||
sendOnSignUp: true,
|
||||
autoSignInAfterVerification: true,
|
||||
sendOnSignIn: true,
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
if (IS_CLOUD) {
|
||||
await sendEmail({
|
||||
await sendVerificationEmail({
|
||||
userName: user.name || "User",
|
||||
email: user.email,
|
||||
subject: "Verify your email",
|
||||
text: `
|
||||
<p>Click the link to verify your email: <a href="${url}">Verify Email</a></p>
|
||||
`,
|
||||
verificationUrl: url,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -30,13 +30,9 @@ export const findPreviewDeploymentById = async (
|
||||
with: {
|
||||
domain: true,
|
||||
application: {
|
||||
with: {
|
||||
server: true,
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
applicationId: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,456 +0,0 @@
|
||||
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] };
|
||||
}
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from "./types";
|
||||
export * from "./scanner";
|
||||
export * from "./sync";
|
||||
export * from "./preflight";
|
||||
@@ -1,100 +0,0 @@
|
||||
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]}`;
|
||||
};
|
||||
@@ -1,300 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,395 +0,0 @@
|
||||
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");
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
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[];
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
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,
|
||||
@@ -26,3 +29,25 @@ 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,
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user