mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-18 21:55:24 +02:00
Compare commits
2 Commits
feat/scim-
...
feat/servi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2880fb9748 | ||
|
|
fcbd226796 |
42
.github/workflows/sync-openapi-docs.yml
vendored
42
.github/workflows/sync-openapi-docs.yml
vendored
@@ -68,45 +68,3 @@ jobs:
|
||||
|
||||
echo "✅ OpenAPI synced to website successfully"
|
||||
|
||||
- name: Sync to MCP repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo
|
||||
|
||||
cd mcp-repo
|
||||
|
||||
cp -f ../openapi.json openapi.json
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
|
||||
git add openapi.json
|
||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
||||
--allow-empty
|
||||
|
||||
git push
|
||||
|
||||
echo "✅ OpenAPI synced to MCP repository successfully"
|
||||
|
||||
- name: Sync to CLI repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo
|
||||
|
||||
cd cli-repo
|
||||
|
||||
cp -f ../openapi.json openapi.json
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
|
||||
git add openapi.json
|
||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
||||
--allow-empty
|
||||
|
||||
git push
|
||||
|
||||
echo "✅ OpenAPI synced to CLI repository successfully"
|
||||
|
||||
|
||||
80
.github/workflows/sync-version.yml
vendored
80
.github/workflows/sync-version.yml
vendored
@@ -1,80 +0,0 @@
|
||||
name: Sync version to MCP and CLI repos
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync-version:
|
||||
name: Sync version to external repos
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Dokploy repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(jq -r .version apps/dokploy/package.json | sed 's/^v//')
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Version: $VERSION"
|
||||
|
||||
- name: Sync version to MCP repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
|
||||
cd /tmp/mcp-repo
|
||||
|
||||
# Regenerate tools from latest OpenAPI spec
|
||||
npm install -g pnpm
|
||||
pnpm install
|
||||
pnpm run fetch-openapi
|
||||
pnpm run generate
|
||||
|
||||
# Bump version after install so pnpm install doesn't overwrite it
|
||||
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
|
||||
git add -A
|
||||
git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
-m "Release: ${{ github.event.release.html_url }}" \
|
||||
--allow-empty
|
||||
|
||||
git push
|
||||
|
||||
|
||||
- name: Sync version to CLI repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
|
||||
|
||||
cd /tmp/cli-repo
|
||||
|
||||
# Copy latest openapi spec and regenerate commands
|
||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
||||
npm install -g pnpm
|
||||
pnpm install
|
||||
pnpm run generate
|
||||
|
||||
# Bump version after install so pnpm install doesn't overwrite it
|
||||
if [ -f package.json ]; then
|
||||
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
fi
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
|
||||
git add -A
|
||||
git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
-m "Release: ${{ github.event.release.html_url }}" \
|
||||
--allow-empty
|
||||
|
||||
git push
|
||||
|
||||
echo "CLI repo synced to version ${{ steps.get_version.outputs.version }}"
|
||||
|
||||
@@ -666,7 +666,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Custom Entrypoint</FormLabel>
|
||||
<FormDescription>
|
||||
Use custom entrypoint for domain
|
||||
Use custom entrypoint for domina
|
||||
<br />
|
||||
"web" and/or "websecure" is used by default.
|
||||
</FormDescription>
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ArrowRight, Rocket, Server } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type DeploymentStatus = "idle" | "running" | "done" | "error";
|
||||
|
||||
const statusDotClass: Record<string, string> = {
|
||||
done: "bg-emerald-500",
|
||||
running: "bg-amber-500",
|
||||
error: "bg-red-500",
|
||||
idle: "bg-muted-foreground/40",
|
||||
};
|
||||
|
||||
function getServiceInfo(d: any) {
|
||||
const app = d.application;
|
||||
const comp = d.compose;
|
||||
const serverName: string =
|
||||
d.server?.name ?? app?.server?.name ?? comp?.server?.name ?? "Dokploy";
|
||||
if (app?.environment?.project && app.environment) {
|
||||
return {
|
||||
name: app.name as string,
|
||||
environment: app.environment.name as string,
|
||||
projectName: app.environment.project.name as string,
|
||||
serverName,
|
||||
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
|
||||
};
|
||||
}
|
||||
if (comp?.environment?.project && comp.environment) {
|
||||
return {
|
||||
name: comp.name as string,
|
||||
environment: comp.environment.name as string,
|
||||
projectName: comp.environment.project.name as string,
|
||||
serverName,
|
||||
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
delta,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
delta?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between">
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-3xl font-semibold tracking-tight">{value}</span>
|
||||
{delta && (
|
||||
<span className="text-xs text-muted-foreground">{delta}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusListCard({
|
||||
label,
|
||||
items,
|
||||
}: {
|
||||
label: string;
|
||||
items: { dotClass: string; label: string; count: number }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col gap-3">
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{items.map((item) => (
|
||||
<li key={item.label} className="flex items-center gap-2.5 text-sm">
|
||||
<span
|
||||
className={`size-2 rounded-full shrink-0 ${item.dotClass}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="font-semibold tabular-nums w-8">{item.count}</span>
|
||||
<span className="text-muted-foreground">{item.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ShowHome = () => {
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const { data: homeStats } = api.project.homeStats.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canReadDeployments = !!permissions?.deployment.read;
|
||||
const { data: deployments } = api.deployment.allCentralized.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: canReadDeployments,
|
||||
refetchInterval: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
const firstName = auth?.user?.firstName?.trim();
|
||||
|
||||
const totals = homeStats ?? {
|
||||
projects: 0,
|
||||
environments: 0,
|
||||
applications: 0,
|
||||
compose: 0,
|
||||
databases: 0,
|
||||
services: 0,
|
||||
};
|
||||
const statusBreakdown = homeStats?.status ?? {
|
||||
running: 0,
|
||||
error: 0,
|
||||
idle: 0,
|
||||
};
|
||||
|
||||
const recentDeployments = useMemo(() => {
|
||||
if (!deployments) return [];
|
||||
return [...deployments]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
)
|
||||
.slice(0, 10);
|
||||
}, [deployments]);
|
||||
|
||||
const deployStats = useMemo(() => {
|
||||
const now = Date.now();
|
||||
const weekMs = 7 * 24 * 60 * 60 * 1000;
|
||||
const lastStart = now - weekMs;
|
||||
const prevStart = now - 2 * weekMs;
|
||||
|
||||
const last: NonNullable<typeof deployments> = [];
|
||||
const prev: NonNullable<typeof deployments> = [];
|
||||
for (const d of deployments ?? []) {
|
||||
const t = new Date(d.createdAt).getTime();
|
||||
if (t >= lastStart) last.push(d);
|
||||
else if (t >= prevStart) prev.push(d);
|
||||
}
|
||||
|
||||
const lastCount = last.length;
|
||||
const prevCount = prev.length;
|
||||
let delta: string | undefined;
|
||||
if (prevCount > 0) {
|
||||
const pct = Math.round(((lastCount - prevCount) / prevCount) * 100);
|
||||
delta = `${pct >= 0 ? "+" : ""}${pct}% vs prev 7d`;
|
||||
} else if (lastCount > 0) {
|
||||
delta = "no prior data";
|
||||
} else {
|
||||
delta = "no activity yet";
|
||||
}
|
||||
|
||||
return { value: String(lastCount), delta };
|
||||
}, [deployments]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl min-h-[85vh]">
|
||||
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-6 h-full">
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
{firstName ? `Welcome back, ${firstName}` : "Welcome back"}
|
||||
</h1>
|
||||
<Button asChild variant="secondary" className="w-fit">
|
||||
<Link href="/dashboard/projects">
|
||||
Go to projects
|
||||
<ArrowRight className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label="Projects"
|
||||
value={String(totals.projects)}
|
||||
delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Services"
|
||||
value={String(totals.services)}
|
||||
delta={`${totals.applications} apps · ${totals.compose} compose · ${totals.databases} db`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Deploys / 7d"
|
||||
value={deployStats.value}
|
||||
delta={deployStats.delta}
|
||||
/>
|
||||
<StatusListCard
|
||||
label="Status"
|
||||
items={[
|
||||
{
|
||||
dotClass: "bg-emerald-500",
|
||||
label: "running",
|
||||
count: statusBreakdown.running,
|
||||
},
|
||||
{
|
||||
dotClass: "bg-red-500",
|
||||
label: "errored",
|
||||
count: statusBreakdown.error,
|
||||
},
|
||||
{
|
||||
dotClass: "bg-muted-foreground/40",
|
||||
label: "idle",
|
||||
count: statusBreakdown.idle,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-background">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Rocket className="size-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold">Recent deployments</h2>
|
||||
</div>
|
||||
{canReadDeployments && (
|
||||
<Link
|
||||
href="/dashboard/deployments"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
view all →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{!canReadDeployments ? (
|
||||
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
|
||||
<Rocket className="size-8 opacity-40" />
|
||||
<span>You do not have permission to view deployments.</span>
|
||||
</div>
|
||||
) : recentDeployments.length === 0 ? (
|
||||
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
|
||||
<Rocket className="size-8 opacity-40" />
|
||||
<span>No deployments yet.</span>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{recentDeployments.map((d) => {
|
||||
const info = getServiceInfo(d);
|
||||
if (!info) return null;
|
||||
const status = (d.status ?? "idle") as DeploymentStatus;
|
||||
return (
|
||||
<li key={d.deploymentId}>
|
||||
<Link
|
||||
href={info.href}
|
||||
className="flex items-center gap-4 px-5 py-4 hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<span
|
||||
className={`size-2 rounded-full shrink-0 ${statusDotClass[status] ?? statusDotClass.idle}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-sm truncate">{info.name}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{info.projectName} · {info.environment}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground w-36 hidden lg:flex items-center justify-end gap-1.5 truncate">
|
||||
<Server className="size-3 shrink-0" />
|
||||
<span className="truncate">{info.serverName}</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground w-20 text-right hidden sm:inline">
|
||||
{status}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground w-24 text-right hidden md:inline">
|
||||
{formatDistanceToNow(new Date(d.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
logs →
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -166,7 +166,6 @@ export const ShowProjects = () => {
|
||||
return (
|
||||
total +
|
||||
(env.applications?.length || 0) +
|
||||
(env.libsql?.length || 0) +
|
||||
(env.mariadb?.length || 0) +
|
||||
(env.mongo?.length || 0) +
|
||||
(env.mysql?.length || 0) +
|
||||
@@ -179,7 +178,6 @@ export const ShowProjects = () => {
|
||||
return (
|
||||
total +
|
||||
(env.applications?.length || 0) +
|
||||
(env.libsql?.length || 0) +
|
||||
(env.mariadb?.length || 0) +
|
||||
(env.mongo?.length || 0) +
|
||||
(env.mysql?.length || 0) +
|
||||
|
||||
@@ -167,7 +167,7 @@ export const SearchCommand = () => {
|
||||
<CommandGroup heading={"Application"} hidden={true}>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
router.push("/dashboard/home");
|
||||
router.push("/dashboard/projects");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -425,7 +425,7 @@ export const WelcomeSubscription = () => {
|
||||
onClick={() => {
|
||||
if (stepper.isLast) {
|
||||
setIsOpen(false);
|
||||
push("/dashboard/home");
|
||||
push("/dashboard/projects");
|
||||
} else {
|
||||
stepper.next();
|
||||
}
|
||||
|
||||
@@ -3,13 +3,15 @@ import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes
|
||||
import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command";
|
||||
import { ShowClusterSettings } from "../application/advanced/cluster/show-cluster-settings";
|
||||
import { RebuildDatabase } from "./rebuild-database";
|
||||
import { TransferService } from "./transfer-service";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "libsql" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
|
||||
serverId?: string | null;
|
||||
}
|
||||
|
||||
export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
|
||||
export const ShowDatabaseAdvancedSettings = ({ id, type, serverId }: Props) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5">
|
||||
<ShowCustomCommand id={id} type={type} />
|
||||
@@ -23,6 +25,13 @@ export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
|
||||
<ShowVolumes id={id} type={type} />
|
||||
<ShowResources id={id} type={type} />
|
||||
<RebuildDatabase id={id} type={type} />
|
||||
{type !== "libsql" && (
|
||||
<TransferService
|
||||
serviceId={id}
|
||||
serviceType={type}
|
||||
currentServerId={serverId ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
596
apps/dokploy/components/dashboard/shared/transfer-service.tsx
Normal file
596
apps/dokploy/components/dashboard/shared/transfer-service.tsx
Normal file
@@ -0,0 +1,596 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRightLeft,
|
||||
Loader2,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
||||
import type { LogLine } from "@/components/dashboard/docker/logs/utils";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type ServiceType =
|
||||
| "application"
|
||||
| "compose"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "redis";
|
||||
|
||||
interface TransferServiceProps {
|
||||
serviceId: string;
|
||||
serviceType: ServiceType;
|
||||
currentServerId: string | null;
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
serviceDirectory: {
|
||||
files: Array<{
|
||||
path: string;
|
||||
status: string;
|
||||
sourceFile: { path: string; size: number; modifiedAt: number };
|
||||
targetFile?: { path: string; size: number; modifiedAt: number };
|
||||
}>;
|
||||
totalSize: number;
|
||||
};
|
||||
traefikConfig: {
|
||||
exists: boolean;
|
||||
hasConflict: boolean;
|
||||
};
|
||||
mounts: Array<{
|
||||
mount: {
|
||||
mountId: string;
|
||||
type: string;
|
||||
volumeName?: string | null;
|
||||
hostPath?: string | null;
|
||||
mountPath: string;
|
||||
};
|
||||
files: Array<{
|
||||
path: string;
|
||||
status: string;
|
||||
}>;
|
||||
totalSize: number;
|
||||
}>;
|
||||
totalTransferSize: number;
|
||||
totalFiles: number;
|
||||
conflicts: Array<{
|
||||
path: string;
|
||||
status: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const useScanMutation = (serviceType: ServiceType) => {
|
||||
const mutations = {
|
||||
application: api.application.transferScan.useMutation(),
|
||||
compose: api.compose.transferScan.useMutation(),
|
||||
postgres: api.postgres.transferScan.useMutation(),
|
||||
mysql: api.mysql.transferScan.useMutation(),
|
||||
mariadb: api.mariadb.transferScan.useMutation(),
|
||||
mongo: api.mongo.transferScan.useMutation(),
|
||||
redis: api.redis.transferScan.useMutation(),
|
||||
};
|
||||
return mutations[serviceType];
|
||||
};
|
||||
|
||||
const getServiceIdKey = (serviceType: ServiceType): string => {
|
||||
const map: Record<ServiceType, string> = {
|
||||
application: "applicationId",
|
||||
compose: "composeId",
|
||||
postgres: "postgresId",
|
||||
mysql: "mysqlId",
|
||||
mariadb: "mariadbId",
|
||||
mongo: "mongoId",
|
||||
redis: "redisId",
|
||||
};
|
||||
return map[serviceType];
|
||||
};
|
||||
|
||||
export const TransferService = ({
|
||||
serviceId,
|
||||
serviceType,
|
||||
currentServerId,
|
||||
}: TransferServiceProps) => {
|
||||
const [targetServerId, setTargetServerId] = useState<string>("");
|
||||
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
||||
const [step, setStep] = useState<"select" | "scan" | "confirm">("select");
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
// Drawer logs state
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||
const [isTransferring, setIsTransferring] = useState(false);
|
||||
|
||||
const { data: servers } = api.server.all.useQuery();
|
||||
const utils = api.useUtils();
|
||||
const scan = useScanMutation(serviceType);
|
||||
|
||||
const idKey = getServiceIdKey(serviceType);
|
||||
|
||||
const availableServers = servers?.filter(
|
||||
(s) => s.serverId !== currentServerId,
|
||||
);
|
||||
|
||||
const selectedServer = servers?.find((s) => s.serverId === targetServerId);
|
||||
|
||||
// Subscription for transfer with logs
|
||||
const subscriptionInput = {
|
||||
[idKey]: serviceId,
|
||||
targetServerId: targetServerId || "placeholder",
|
||||
decisions: {},
|
||||
};
|
||||
|
||||
const useTransferSubscription = (sType: ServiceType) => {
|
||||
api.application.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "application",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
api.compose.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "compose",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
api.postgres.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "postgres",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
api.mysql.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "mysql",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
api.mariadb.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "mariadb",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
api.mongo.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "mongo",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
api.redis.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "redis",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogData = (log: string) => {
|
||||
if (!isDrawerOpen) {
|
||||
setIsDrawerOpen(true);
|
||||
}
|
||||
|
||||
// Try to parse as JSON progress
|
||||
try {
|
||||
const progress = JSON.parse(log);
|
||||
if (progress.message) {
|
||||
const logLine: LogLine = {
|
||||
rawTimestamp: new Date().toISOString(),
|
||||
timestamp: new Date(),
|
||||
message: `[${progress.phase || "transfer"}] ${progress.message}`,
|
||||
};
|
||||
setFilteredLogs((prev) => [...prev, logLine]);
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
// Not JSON, treat as plain text
|
||||
}
|
||||
|
||||
const logLine: LogLine = {
|
||||
rawTimestamp: new Date().toISOString(),
|
||||
timestamp: new Date(),
|
||||
message: log,
|
||||
};
|
||||
setFilteredLogs((prev) => [...prev, logLine]);
|
||||
|
||||
if (
|
||||
log.includes("completed successfully") ||
|
||||
log.includes("Deployment queued") ||
|
||||
log.includes("Deployment started")
|
||||
) {
|
||||
setTimeout(() => {
|
||||
setIsTransferring(false);
|
||||
utils.invalidate();
|
||||
toast.success("Transfer and deployment completed!");
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
if (log.includes("Transfer failed") || log.includes("Transfer error")) {
|
||||
setIsTransferring(false);
|
||||
toast.error("Transfer failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogError = (error: unknown) => {
|
||||
console.error("Transfer subscription error:", error);
|
||||
setIsTransferring(false);
|
||||
const logLine: LogLine = {
|
||||
rawTimestamp: new Date().toISOString(),
|
||||
timestamp: new Date(),
|
||||
message: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
setFilteredLogs((prev) => [...prev, logLine]);
|
||||
};
|
||||
|
||||
// Register the subscription hooks (must be called unconditionally)
|
||||
useTransferSubscription(serviceType);
|
||||
|
||||
const handleScan = async () => {
|
||||
if (!targetServerId) {
|
||||
toast.error("Please select a target server");
|
||||
return;
|
||||
}
|
||||
|
||||
setStep("scan");
|
||||
try {
|
||||
const result = await scan.mutateAsync({
|
||||
[idKey]: serviceId,
|
||||
targetServerId,
|
||||
} as any);
|
||||
setScanResult(result as ScanResult);
|
||||
setStep("confirm");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Scan failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
setStep("select");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTransfer = async () => {
|
||||
setShowConfirm(false);
|
||||
setFilteredLogs([]);
|
||||
setIsTransferring(true);
|
||||
setIsDrawerOpen(true);
|
||||
|
||||
// Add initial log
|
||||
setFilteredLogs([
|
||||
{
|
||||
rawTimestamp: new Date().toISOString(),
|
||||
timestamp: new Date(),
|
||||
message: `Starting transfer to ${selectedServer?.name} (${selectedServer?.ipAddress})...`,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const isDbService = [
|
||||
"postgres",
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"mongo",
|
||||
"redis",
|
||||
].includes(serviceType);
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<ArrowRightLeft className="size-5" />
|
||||
Transfer Service
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Transfer this {serviceType} service to a different server. Source data
|
||||
is never modified or deleted.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!availableServers?.length ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Server className="size-4" />
|
||||
<span>
|
||||
No other servers available. Add a remote server first.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Step 1: Select target server */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">Target Server</span>
|
||||
<Select
|
||||
value={targetServerId}
|
||||
onValueChange={(value) => {
|
||||
setTargetServerId(value);
|
||||
setScanResult(null);
|
||||
setStep("select");
|
||||
}}
|
||||
disabled={isTransferring}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select target server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{availableServers.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverId}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{server.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{server.ipAddress}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Servers ({availableServers.length})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Scan button */}
|
||||
{step === "select" && targetServerId && (
|
||||
<Button
|
||||
onClick={handleScan}
|
||||
disabled={scan.isPending}
|
||||
variant="outline"
|
||||
>
|
||||
{scan.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Scanning...
|
||||
</>
|
||||
) : (
|
||||
"Scan for Transfer"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Step 2: Scan in progress */}
|
||||
{step === "scan" && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>
|
||||
Scanning source and target servers for files and
|
||||
conflicts...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Scan results + confirm */}
|
||||
{step === "confirm" && scanResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<h4 className="font-medium">Scan Results</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Total Files:
|
||||
</span>{" "}
|
||||
<span className="font-medium">
|
||||
{scanResult.totalFiles}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Transfer Size:
|
||||
</span>{" "}
|
||||
<span className="font-medium">
|
||||
{formatBytes(scanResult.totalTransferSize)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Volumes/Mounts:
|
||||
</span>{" "}
|
||||
<span className="font-medium">
|
||||
{scanResult.mounts.length}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Conflicts:
|
||||
</span>{" "}
|
||||
<Badge
|
||||
variant={
|
||||
scanResult.conflicts.length > 0
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{scanResult.conflicts.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{scanResult.traefikConfig.exists && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Traefik Config:
|
||||
</span>{" "}
|
||||
<Badge variant="outline">Will be synced</Badge>
|
||||
</div>
|
||||
)}
|
||||
{scanResult.mounts.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Docker Volumes:
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{scanResult.mounts.map((m) => (
|
||||
<Badge
|
||||
key={m.mount.mountId}
|
||||
variant="outline"
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{m.mount.volumeName ||
|
||||
m.mount.hostPath ||
|
||||
m.mount.mountPath}
|
||||
{m.totalSize > 0 && (
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
({formatBytes(m.totalSize)})
|
||||
</span>
|
||||
)}
|
||||
{m.files.length > 0 && (
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
{m.files.length} files
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conflict list */}
|
||||
{scanResult.conflicts.length > 0 && (
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h4 className="font-medium text-sm">
|
||||
File Conflicts (will be overwritten)
|
||||
</h4>
|
||||
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||
{scanResult.conflicts.map((conflict) => (
|
||||
<div
|
||||
key={conflict.path}
|
||||
className="text-xs font-mono flex items-center gap-2"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px]"
|
||||
>
|
||||
{conflict.status}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{conflict.path}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning */}
|
||||
<div className="rounded-lg border border-yellow-500/50 bg-yellow-500/10 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
|
||||
<AlertTriangle className="size-4" />
|
||||
<span className="font-medium text-sm">
|
||||
Service Downtime Warning
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isDbService
|
||||
? "Stop the database service before transferring to avoid data corruption. After transfer completes, the service will be automatically deployed on the target server."
|
||||
: "The service will be unavailable during transfer. After transfer completes, the service will be automatically deployed on the target server."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Transfer button */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setStep("select");
|
||||
setScanResult(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowConfirm(true)}
|
||||
disabled={isTransferring}
|
||||
>
|
||||
<ArrowRightLeft className="mr-2 size-4" />
|
||||
Transfer to {selectedServer?.name}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Confirmation dialog */}
|
||||
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Service Transfer</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-2">
|
||||
<p>
|
||||
You are about to transfer this {serviceType} to{" "}
|
||||
<strong>{selectedServer?.name}</strong> (
|
||||
{selectedServer?.ipAddress}).
|
||||
</p>
|
||||
{scanResult && (
|
||||
<p>
|
||||
{scanResult.totalFiles} files (
|
||||
{formatBytes(scanResult.totalTransferSize)}) will be
|
||||
copied.
|
||||
{scanResult.mounts.length > 0 &&
|
||||
` ${scanResult.mounts.length} volume(s) will be transferred.`}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-yellow-600 dark:text-yellow-400 font-medium">
|
||||
The service will experience downtime during this
|
||||
process. After transfer, the service will be
|
||||
automatically deployed on the target server.
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleTransfer}>
|
||||
Confirm Transfer
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Drawer for transfer logs */}
|
||||
<DrawerLogs
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => {
|
||||
setIsDrawerOpen(false);
|
||||
if (!isTransferring) {
|
||||
setFilteredLogs([]);
|
||||
setStep("select");
|
||||
setScanResult(null);
|
||||
}
|
||||
}}
|
||||
filteredLogs={filteredLogs}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
Forward,
|
||||
GalleryVerticalEnd,
|
||||
GitBranch,
|
||||
House,
|
||||
Key,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
@@ -149,12 +148,6 @@ type Menu = {
|
||||
// The `isEnabled` function is called to determine if the item should be displayed
|
||||
const MENU: Menu = {
|
||||
home: [
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Home",
|
||||
url: "/dashboard/home",
|
||||
icon: House,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Projects",
|
||||
|
||||
@@ -80,7 +80,7 @@ export const UserNav = () => {
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
router.push("/dashboard/home");
|
||||
router.push("/dashboard/projects");
|
||||
}}
|
||||
>
|
||||
Projects
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Copy, KeyRound, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { useUrl } from "@/utils/hooks/use-url";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ScimDialog = ({ children }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const baseURL = useUrl();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [newProviderId, setNewProviderId] = useState("");
|
||||
const [justCreatedToken, setJustCreatedToken] = useState<{
|
||||
providerId: string;
|
||||
token: string;
|
||||
} | null>(null);
|
||||
|
||||
const { data: providers = [], isPending } = api.scim.listProviders.useQuery(
|
||||
undefined,
|
||||
{ enabled: open },
|
||||
);
|
||||
const { mutateAsync: generateToken, isPending: isGenerating } =
|
||||
api.scim.generateToken.useMutation();
|
||||
const { mutateAsync: deleteProvider, isPending: isDeleting } =
|
||||
api.scim.deleteProvider.useMutation();
|
||||
|
||||
const scimUrl = `${baseURL || "{baseURL}"}/api/auth/scim/v2`;
|
||||
|
||||
const handleGenerate = async () => {
|
||||
const providerId = newProviderId.trim().toLowerCase();
|
||||
if (!providerId) return;
|
||||
try {
|
||||
const result = await generateToken({ providerId });
|
||||
setJustCreatedToken({
|
||||
providerId: result.providerId,
|
||||
token: result.scimToken,
|
||||
});
|
||||
setNewProviderId("");
|
||||
await utils.scim.listProviders.invalidate();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to generate SCIM token",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (providerId: string) => {
|
||||
try {
|
||||
await deleteProvider({ providerId });
|
||||
toast.success("SCIM provider removed");
|
||||
await utils.scim.listProviders.invalidate();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to delete SCIM provider",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async (value: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success(`${label} copied`);
|
||||
} catch {
|
||||
toast.error("Failed to copy");
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
if (!next) setJustCreatedToken(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<KeyRound className="size-5" />
|
||||
SCIM provisioning
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Automatically provision, update, and deactivate users from your
|
||||
identity provider (Okta, Entra ID, etc.). Configure the SCIM endpoint
|
||||
below in your IdP.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
SCIM 2.0 endpoint URL
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="flex-1 break-all rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
|
||||
{scimUrl}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
onClick={() => handleCopy(scimUrl, "Endpoint URL")}
|
||||
disabled={!baseURL}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{justCreatedToken && (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3">
|
||||
<p className="text-sm font-medium">
|
||||
Bearer token for {justCreatedToken.providerId}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Copy this token now — it will not be shown again. Paste it into
|
||||
your IdP's SCIM configuration.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<p className="flex-1 break-all rounded-md bg-background px-2 py-1.5 font-mono text-xs">
|
||||
{justCreatedToken.token}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
onClick={() =>
|
||||
handleCopy(justCreatedToken.token, "Bearer token")
|
||||
}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
Generate token for a new provider
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newProviderId}
|
||||
onChange={(e) => setNewProviderId(e.target.value)}
|
||||
placeholder="okta, entra, jumpcloud..."
|
||||
className="font-mono text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleGenerate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleGenerate}
|
||||
disabled={!newProviderId.trim() || isGenerating}
|
||||
>
|
||||
<Plus className="mr-1 size-4" />
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose a unique identifier for this IdP connection (lowercase,
|
||||
alphanumeric, dashes).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Existing providers</Label>
|
||||
{isPending ? (
|
||||
<div className="flex items-center gap-2 justify-center py-4">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed bg-muted/30 px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
No SCIM providers configured yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{providers.map((provider) => (
|
||||
<li
|
||||
key={provider.id}
|
||||
className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2"
|
||||
>
|
||||
<span className="flex-1 font-mono text-sm">
|
||||
{provider.providerId}
|
||||
</span>
|
||||
<DialogAction
|
||||
title="Remove SCIM provider"
|
||||
description={`Remove "${provider.providerId}"? Existing provisioned users will stay but the IdP will no longer be able to sync.`}
|
||||
type="destructive"
|
||||
onClick={() => handleDelete(provider.providerId)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 shrink-0 text-destructive hover:text-destructive"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -44,7 +44,7 @@ export function SignInWithSSO({ children }: SignInWithSSOProps) {
|
||||
try {
|
||||
const { data, error } = await authClient.signIn.sso({
|
||||
email: values.email,
|
||||
callbackURL: "/dashboard/home",
|
||||
callbackURL: "/dashboard/projects",
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Failed to sign in with SSO");
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {
|
||||
Eye,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
LogIn,
|
||||
Pencil,
|
||||
@@ -35,7 +34,6 @@ import { api } from "@/utils/api";
|
||||
import { useUrl } from "@/utils/hooks/use-url";
|
||||
import { RegisterOidcDialog } from "./register-oidc-dialog";
|
||||
import { RegisterSamlDialog } from "./register-saml-dialog";
|
||||
import { ScimDialog } from "./scim-dialog";
|
||||
|
||||
type ProviderForDetails = {
|
||||
id: string | null;
|
||||
@@ -171,22 +169,15 @@ export const SSOSettings = () => {
|
||||
Users can sign in with their organization's IdP.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setManageOriginsOpen(true)}
|
||||
>
|
||||
<Shield className="mr-2 size-4" />
|
||||
Manage origins
|
||||
</Button>
|
||||
<ScimDialog>
|
||||
<Button variant="outline" size="sm">
|
||||
<KeyRound className="mr-2 size-4" />
|
||||
Manage SCIM
|
||||
</Button>
|
||||
</ScimDialog>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setManageOriginsOpen(true)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Shield className="mr-2 size-4" />
|
||||
Manage origins
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isPending ? (
|
||||
|
||||
@@ -116,14 +116,6 @@ export function TagSelector({
|
||||
<HandleTag />
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
{tags.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 py-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No tags created yet.
|
||||
</span>
|
||||
<HandleTag />
|
||||
</div>
|
||||
)}
|
||||
<CommandGroup>
|
||||
{tags.map((tag) => {
|
||||
const isSelected = selectedTags.includes(tag.id);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
CREATE TABLE "scim_provider" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"provider_id" text NOT NULL,
|
||||
"scim_token" text NOT NULL,
|
||||
"organization_id" text,
|
||||
CONSTRAINT "scim_provider_provider_id_unique" UNIQUE("provider_id"),
|
||||
CONSTRAINT "scim_provider_scim_token_unique" UNIQUE("scim_token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "two_factor" ADD COLUMN "verified" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "scim_provider" ADD CONSTRAINT "scim_provider_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1163,13 +1163,6 @@
|
||||
"when": 1775845419261,
|
||||
"tag": "0165_abnormal_greymalkin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 166,
|
||||
"version": "7",
|
||||
"when": 1776576422440,
|
||||
"tag": "0166_overjoyed_big_bertha",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -46,8 +46,8 @@
|
||||
"@ai-sdk/mistral": "^3.0.20",
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@better-auth/api-key": "1.6.5",
|
||||
"@better-auth/sso": "1.6.5",
|
||||
"@better-auth/api-key": "1.5.4",
|
||||
"@better-auth/sso": "1.5.4",
|
||||
"@codemirror/autocomplete": "^6.18.6",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
@@ -101,7 +101,7 @@
|
||||
"ai": "^6.0.86",
|
||||
"ai-sdk-ollama": "^3.7.0",
|
||||
"bcrypt": "5.1.1",
|
||||
"better-auth": "1.6.5",
|
||||
"better-auth": "1.5.4",
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
"bullmq": "5.67.3",
|
||||
@@ -113,7 +113,7 @@
|
||||
"dockerode": "4.0.2",
|
||||
"dompurify": "^3.3.3",
|
||||
"dotenv": "16.4.5",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"drizzle-zod": "0.8.3",
|
||||
"fancy-ansi": "^0.1.3",
|
||||
"input-otp": "^1.4.2",
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function Custom404({ statusCode, error }: Props) {
|
||||
|
||||
<div className="mt-5 flex flex-col justify-center items-center gap-2 sm:flex-row sm:gap-3">
|
||||
<Link
|
||||
href="/dashboard/home"
|
||||
href="/dashboard/projects"
|
||||
className={buttonVariants({
|
||||
variant: "secondary",
|
||||
className: "flex flex-row gap-2",
|
||||
|
||||
@@ -102,7 +102,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { ShowHome } from "@/components/dashboard/home/show-home";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
const Home = () => {
|
||||
return <ShowHome />;
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
||||
Home.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout>{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
const { req, res } = ctx;
|
||||
const { user, session } = await validateRequest(req);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: res as any,
|
||||
db: null as any,
|
||||
session: session as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
|
||||
await helpers.settings.isCloud.prefetch();
|
||||
await helpers.user.get.prefetch();
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -122,7 +122,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -509,14 +509,6 @@ const EnvironmentPage = (
|
||||
deploy: api.mongo.deploy.useMutation(),
|
||||
};
|
||||
|
||||
const libsqlActions = {
|
||||
start: api.libsql.start.useMutation(),
|
||||
stop: api.libsql.stop.useMutation(),
|
||||
move: api.libsql.move.useMutation(),
|
||||
delete: api.libsql.remove.useMutation(),
|
||||
deploy: api.libsql.deploy.useMutation(),
|
||||
};
|
||||
|
||||
const handleBulkStart = async () => {
|
||||
let success = 0;
|
||||
setIsBulkActionLoading(true);
|
||||
@@ -549,9 +541,6 @@ const EnvironmentPage = (
|
||||
case "mongo":
|
||||
await mongoActions.start.mutateAsync({ mongoId: serviceId });
|
||||
break;
|
||||
case "libsql":
|
||||
await libsqlActions.start.mutateAsync({ libsqlId: serviceId });
|
||||
break;
|
||||
}
|
||||
success++;
|
||||
} catch {
|
||||
@@ -599,9 +588,6 @@ const EnvironmentPage = (
|
||||
case "mongo":
|
||||
await mongoActions.stop.mutateAsync({ mongoId: serviceId });
|
||||
break;
|
||||
case "libsql":
|
||||
await libsqlActions.stop.mutateAsync({ libsqlId: serviceId });
|
||||
break;
|
||||
}
|
||||
success++;
|
||||
} catch {
|
||||
@@ -678,12 +664,6 @@ const EnvironmentPage = (
|
||||
targetEnvironmentId: selectedTargetEnvironment,
|
||||
});
|
||||
break;
|
||||
case "libsql":
|
||||
await libsqlActions.move.mutateAsync({
|
||||
libsqlId: serviceId,
|
||||
targetEnvironmentId: selectedTargetEnvironment,
|
||||
});
|
||||
break;
|
||||
}
|
||||
await utils.environment.one.invalidate({
|
||||
environmentId,
|
||||
@@ -753,11 +733,6 @@ const EnvironmentPage = (
|
||||
mongoId: serviceId,
|
||||
});
|
||||
break;
|
||||
case "libsql":
|
||||
await libsqlActions.delete.mutateAsync({
|
||||
libsqlId: serviceId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
await utils.environment.one.invalidate({
|
||||
environmentId,
|
||||
@@ -824,11 +799,6 @@ const EnvironmentPage = (
|
||||
mongoId: serviceId,
|
||||
});
|
||||
break;
|
||||
case "libsql":
|
||||
await libsqlActions.deploy.mutateAsync({
|
||||
libsqlId: serviceId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
success++;
|
||||
} catch (error) {
|
||||
@@ -1886,7 +1856,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ShowPorts } from "@/components/dashboard/application/advanced/ports/sho
|
||||
import { ShowRedirects } from "@/components/dashboard/application/advanced/redirects/show-redirects";
|
||||
import { ShowSecurity } from "@/components/dashboard/application/advanced/security/show-security";
|
||||
import { ShowBuildServer } from "@/components/dashboard/application/advanced/show-build-server";
|
||||
import { TransferService } from "@/components/dashboard/shared/transfer-service";
|
||||
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
|
||||
import { ShowTraefikConfig } from "@/components/dashboard/application/advanced/traefik/show-traefik-config";
|
||||
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
|
||||
@@ -419,6 +420,11 @@ const Service = (
|
||||
<ShowSecurity applicationId={applicationId} />
|
||||
<ShowPorts applicationId={applicationId} />
|
||||
<ShowTraefikConfig applicationId={applicationId} />
|
||||
<TransferService
|
||||
serviceId={applicationId}
|
||||
serviceType="application"
|
||||
currentServerId={data?.serverId ?? null}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
@@ -490,7 +496,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ShowSchedules } from "@/components/dashboard/application/schedules/show
|
||||
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
||||
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
||||
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
|
||||
import { TransferService } from "@/components/dashboard/shared/transfer-service";
|
||||
import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers";
|
||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
|
||||
@@ -423,6 +424,11 @@ const Service = (
|
||||
<ShowVolumes id={composeId} type="compose" />
|
||||
<ShowImport composeId={composeId} />
|
||||
<IsolatedDeploymentTab composeId={composeId} />
|
||||
<TransferService
|
||||
serviceId={composeId}
|
||||
serviceType="compose"
|
||||
currentServerId={data?.serverId ?? null}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
@@ -492,7 +498,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -343,7 +343,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -303,6 +303,7 @@ const Mariadb = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={mariadbId}
|
||||
type="mariadb"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -372,7 +373,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -307,6 +307,7 @@ const Mongo = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={mongoId}
|
||||
type="mongo"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -376,7 +377,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -284,6 +284,7 @@ const MySql = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={mysqlId}
|
||||
type="mysql"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -353,7 +354,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -292,6 +292,7 @@ const Postgresql = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={postgresId}
|
||||
type="postgres"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -360,7 +361,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -296,6 +296,7 @@ const Redis = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={redisId}
|
||||
type="redis"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -363,7 +364,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function getServerSideProps(
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,16 +82,6 @@ export default function Home({ IS_CLOUD }: Props) {
|
||||
});
|
||||
|
||||
if (error) {
|
||||
const isEmailNotVerified =
|
||||
error.code === "EMAIL_NOT_VERIFIED" ||
|
||||
error.message?.toLowerCase().includes("email not verified");
|
||||
if (isEmailNotVerified) {
|
||||
const msg =
|
||||
"Your email is not verified. We've sent a new verification link to your email.";
|
||||
toast.info(msg);
|
||||
setError(msg);
|
||||
return;
|
||||
}
|
||||
toast.error(error.message);
|
||||
setError(error.message || "An error occurred while logging in");
|
||||
return;
|
||||
@@ -106,7 +96,7 @@ export default function Home({ IS_CLOUD }: Props) {
|
||||
}
|
||||
|
||||
toast.success("Logged in successfully");
|
||||
router.push("/dashboard/home");
|
||||
router.push("/dashboard/projects");
|
||||
} catch {
|
||||
toast.error("An error occurred while logging in");
|
||||
} finally {
|
||||
@@ -133,7 +123,7 @@ export default function Home({ IS_CLOUD }: Props) {
|
||||
}
|
||||
|
||||
toast.success("Logged in successfully");
|
||||
router.push("/dashboard/home");
|
||||
router.push("/dashboard/projects");
|
||||
} catch {
|
||||
toast.error("An error occurred while verifying 2FA code");
|
||||
} finally {
|
||||
@@ -163,7 +153,7 @@ export default function Home({ IS_CLOUD }: Props) {
|
||||
}
|
||||
|
||||
toast.success("Logged in successfully");
|
||||
router.push("/dashboard/home");
|
||||
router.push("/dashboard/projects");
|
||||
} catch {
|
||||
toast.error("An error occurred while verifying backup code");
|
||||
} finally {
|
||||
@@ -408,7 +398,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -437,7 +427,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ const Invitation = ({
|
||||
});
|
||||
|
||||
toast.success("Account created successfully");
|
||||
router.push("/dashboard/home");
|
||||
router.push("/dashboard/projects");
|
||||
} catch {
|
||||
toast.error("An error occurred while creating your account");
|
||||
}
|
||||
|
||||
@@ -303,7 +303,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/home",
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ import { projectRouter } from "./routers/project";
|
||||
import { auditLogRouter } from "./routers/proprietary/audit-log";
|
||||
import { customRoleRouter } from "./routers/proprietary/custom-role";
|
||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||
import { scimRouter } from "./routers/proprietary/scim";
|
||||
import { ssoRouter } from "./routers/proprietary/sso";
|
||||
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
||||
import { redirectsRouter } from "./routers/redirects";
|
||||
@@ -94,7 +93,6 @@ export const appRouter = createTRPCRouter({
|
||||
organization: organizationRouter,
|
||||
licenseKey: licenseKeyRouter,
|
||||
sso: ssoRouter,
|
||||
scim: scimRouter,
|
||||
whitelabeling: whitelabelingRouter,
|
||||
customRole: customRoleRouter,
|
||||
auditLog: auditLogRouter,
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
updateDeploymentStatus,
|
||||
writeConfig,
|
||||
writeConfigRemote,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -62,6 +64,7 @@ import {
|
||||
apiSaveGithubProvider,
|
||||
apiSaveGitlabProvider,
|
||||
apiSaveGitProvider,
|
||||
apiTransferApplication,
|
||||
apiUpdateApplication,
|
||||
applications,
|
||||
environments,
|
||||
@@ -1137,4 +1140,180 @@ export const applicationRouter = createTRPCRouter({
|
||||
application.serverId,
|
||||
);
|
||||
}),
|
||||
|
||||
transferScan: protectedProcedure
|
||||
.input(apiTransferApplication.pick({ applicationId: true, targetServerId: true }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const application = await findApplicationById(input.applicationId);
|
||||
await checkServicePermissionAndAccess(ctx, input.applicationId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
application.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this application",
|
||||
});
|
||||
}
|
||||
|
||||
return await scanServiceForTransfer({
|
||||
serviceId: input.applicationId,
|
||||
serviceType: "application",
|
||||
appName: application.appName,
|
||||
sourceServerId: application.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
});
|
||||
}),
|
||||
|
||||
transferWithLogs: protectedProcedure
|
||||
.input(apiTransferApplication)
|
||||
.subscription(async function* ({ input, ctx, signal }) {
|
||||
const application = await findApplicationById(input.applicationId);
|
||||
await checkServicePermissionAndAccess(ctx, input.applicationId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
application.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this application",
|
||||
});
|
||||
}
|
||||
|
||||
const queue: string[] = [];
|
||||
let done = false;
|
||||
|
||||
executeTransfer(
|
||||
{
|
||||
serviceId: input.applicationId,
|
||||
serviceType: "application",
|
||||
appName: application.appName,
|
||||
sourceServerId: application.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
(progress) => {
|
||||
queue.push(JSON.stringify(progress));
|
||||
},
|
||||
)
|
||||
.then(async (result) => {
|
||||
if (result.success) {
|
||||
await db
|
||||
.update(applications)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(applications.applicationId, input.applicationId));
|
||||
queue.push("Transfer completed! Starting deployment on target server...");
|
||||
|
||||
// Auto-deploy on target server
|
||||
const jobData: DeploymentJob = {
|
||||
applicationId: input.applicationId,
|
||||
titleLog: "Transfer deployment",
|
||||
type: "deploy",
|
||||
applicationType: "application",
|
||||
descriptionLog: "Auto-deploy after transfer to new server",
|
||||
server: true,
|
||||
};
|
||||
|
||||
if (IS_CLOUD) {
|
||||
jobData.serverId = input.targetServerId;
|
||||
deploy(jobData).catch(() => {});
|
||||
} else {
|
||||
await myQueue.add("deployments", jobData, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
queue.push("Deployment queued successfully!");
|
||||
} else {
|
||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
queue.push(
|
||||
`Transfer error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
done = true;
|
||||
});
|
||||
|
||||
while (!done || queue.length > 0) {
|
||||
if (queue.length > 0) {
|
||||
yield queue.shift()!;
|
||||
} else {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
transfer: protectedProcedure
|
||||
.input(apiTransferApplication)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const application = await findApplicationById(input.applicationId);
|
||||
await checkServicePermissionAndAccess(ctx, input.applicationId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
application.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this application",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await executeTransfer(
|
||||
{
|
||||
serviceId: input.applicationId,
|
||||
serviceType: "application",
|
||||
appName: application.appName,
|
||||
sourceServerId: application.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Transfer failed: ${result.errors.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
await db
|
||||
.update(applications)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(applications.applicationId, input.applicationId));
|
||||
|
||||
// Auto-deploy on target server
|
||||
const jobData: DeploymentJob = {
|
||||
applicationId: input.applicationId,
|
||||
titleLog: "Transfer deployment",
|
||||
type: "deploy",
|
||||
applicationType: "application",
|
||||
descriptionLog: "Auto-deploy after transfer to new server",
|
||||
server: true,
|
||||
};
|
||||
|
||||
if (IS_CLOUD) {
|
||||
jobData.serverId = input.targetServerId;
|
||||
deploy(jobData).catch(() => {});
|
||||
} else {
|
||||
await myQueue.add("deployments", jobData, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
stopCompose,
|
||||
updateCompose,
|
||||
updateDeploymentStatus,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -63,6 +65,7 @@ import {
|
||||
apiRandomizeCompose,
|
||||
apiRedeployCompose,
|
||||
apiSaveEnvironmentVariablesCompose,
|
||||
apiTransferCompose,
|
||||
apiUpdateCompose,
|
||||
compose as composeTable,
|
||||
environments,
|
||||
@@ -1171,4 +1174,179 @@ export const composeRouter = createTRPCRouter({
|
||||
true,
|
||||
);
|
||||
}),
|
||||
|
||||
transferScan: protectedProcedure
|
||||
.input(apiTransferCompose.pick({ composeId: true, targetServerId: true }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const compose = await findComposeById(input.composeId);
|
||||
await checkServicePermissionAndAccess(ctx, input.composeId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
compose.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this compose",
|
||||
});
|
||||
}
|
||||
|
||||
return await scanServiceForTransfer({
|
||||
serviceId: input.composeId,
|
||||
serviceType: "compose",
|
||||
appName: compose.appName,
|
||||
sourceServerId: compose.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
});
|
||||
}),
|
||||
|
||||
transferWithLogs: protectedProcedure
|
||||
.input(apiTransferCompose)
|
||||
.subscription(async function* ({ input, ctx, signal }) {
|
||||
const compose = await findComposeById(input.composeId);
|
||||
await checkServicePermissionAndAccess(ctx, input.composeId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
compose.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this compose",
|
||||
});
|
||||
}
|
||||
|
||||
const queue: string[] = [];
|
||||
let done = false;
|
||||
|
||||
executeTransfer(
|
||||
{
|
||||
serviceId: input.composeId,
|
||||
serviceType: "compose",
|
||||
appName: compose.appName,
|
||||
sourceServerId: compose.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
(progress) => {
|
||||
queue.push(JSON.stringify(progress));
|
||||
},
|
||||
)
|
||||
.then(async (result) => {
|
||||
if (result.success) {
|
||||
await db
|
||||
.update(composeTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(composeTable.composeId, input.composeId));
|
||||
queue.push("Transfer completed! Starting deployment on target server...");
|
||||
|
||||
const jobData: DeploymentJob = {
|
||||
composeId: input.composeId,
|
||||
titleLog: "Transfer deployment",
|
||||
type: "deploy",
|
||||
applicationType: "compose",
|
||||
descriptionLog: "Auto-deploy after transfer to new server",
|
||||
server: true,
|
||||
};
|
||||
|
||||
if (IS_CLOUD) {
|
||||
jobData.serverId = input.targetServerId;
|
||||
deploy(jobData).catch(() => {});
|
||||
} else {
|
||||
await myQueue.add("deployments", jobData, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
queue.push("Deployment queued successfully!");
|
||||
} else {
|
||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
queue.push(
|
||||
`Transfer error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
done = true;
|
||||
});
|
||||
|
||||
while (!done || queue.length > 0) {
|
||||
if (queue.length > 0) {
|
||||
yield queue.shift()!;
|
||||
} else {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
transfer: protectedProcedure
|
||||
.input(apiTransferCompose)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const compose = await findComposeById(input.composeId);
|
||||
await checkServicePermissionAndAccess(ctx, input.composeId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
compose.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this compose",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await executeTransfer(
|
||||
{
|
||||
serviceId: input.composeId,
|
||||
serviceType: "compose",
|
||||
appName: compose.appName,
|
||||
sourceServerId: compose.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Transfer failed: ${result.errors.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
await db
|
||||
.update(composeTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(composeTable.composeId, input.composeId));
|
||||
|
||||
// Auto-deploy on target server
|
||||
const jobData: DeploymentJob = {
|
||||
composeId: input.composeId,
|
||||
titleLog: "Transfer deployment",
|
||||
type: "deploy",
|
||||
applicationType: "compose",
|
||||
descriptionLog: "Auto-deploy after transfer to new server",
|
||||
server: true,
|
||||
};
|
||||
|
||||
if (IS_CLOUD) {
|
||||
jobData.serverId = input.targetServerId;
|
||||
deploy(jobData).catch(() => {});
|
||||
} else {
|
||||
await myQueue.add("deployments", jobData, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
stopService,
|
||||
stopServiceRemote,
|
||||
updateMariadbById,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -44,6 +46,7 @@ import {
|
||||
apiResetMariadb,
|
||||
apiSaveEnvironmentVariablesMariaDB,
|
||||
apiSaveExternalPortMariaDB,
|
||||
apiTransferMariadb,
|
||||
apiUpdateMariaDB,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
@@ -626,4 +629,125 @@ export const mariadbRouter = createTRPCRouter({
|
||||
mariadb.serverId,
|
||||
);
|
||||
}),
|
||||
|
||||
transferScan: protectedProcedure
|
||||
.input(apiTransferMariadb.pick({ mariadbId: true, targetServerId: true }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mariadb = await findMariadbById(input.mariadbId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mariadb.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MariaDB",
|
||||
});
|
||||
}
|
||||
return await scanServiceForTransfer({
|
||||
serviceId: input.mariadbId,
|
||||
serviceType: "mariadb",
|
||||
appName: mariadb.appName,
|
||||
sourceServerId: mariadb.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
});
|
||||
}),
|
||||
|
||||
transferWithLogs: protectedProcedure
|
||||
.input(apiTransferMariadb)
|
||||
.subscription(async function* ({ input, ctx, signal }) {
|
||||
const mariadb = await findMariadbById(input.mariadbId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mariadb.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MariaDB",
|
||||
});
|
||||
}
|
||||
const queue: string[] = [];
|
||||
let done = false;
|
||||
executeTransfer(
|
||||
{
|
||||
serviceId: input.mariadbId,
|
||||
serviceType: "mariadb",
|
||||
appName: mariadb.appName,
|
||||
sourceServerId: mariadb.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
(progress) => { queue.push(JSON.stringify(progress)); },
|
||||
)
|
||||
.then(async (result) => {
|
||||
if (result.success) {
|
||||
await db
|
||||
.update(mariadbTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(mariadbTable.mariadbId, input.mariadbId));
|
||||
queue.push("Transfer completed! Starting deployment on target server...");
|
||||
await deployMariadb(input.mariadbId).catch(() => {});
|
||||
queue.push("Deployment started!");
|
||||
} else {
|
||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
})
|
||||
.finally(() => { done = true; });
|
||||
|
||||
while (!done || queue.length > 0) {
|
||||
if (queue.length > 0) { yield queue.shift()!; }
|
||||
else { await new Promise((r) => setTimeout(r, 50)); }
|
||||
if (signal?.aborted) { return; }
|
||||
}
|
||||
}),
|
||||
|
||||
transfer: protectedProcedure
|
||||
.input(apiTransferMariadb)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mariadb = await findMariadbById(input.mariadbId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mariadb.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MariaDB",
|
||||
});
|
||||
}
|
||||
const result = await executeTransfer(
|
||||
{
|
||||
serviceId: input.mariadbId,
|
||||
serviceType: "mariadb",
|
||||
appName: mariadb.appName,
|
||||
sourceServerId: mariadb.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Transfer failed: ${result.errors.join(", ")}`,
|
||||
});
|
||||
}
|
||||
await db
|
||||
.update(mariadbTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(mariadbTable.mariadbId, input.mariadbId));
|
||||
|
||||
await deployMariadb(input.mariadbId).catch(() => {});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
stopService,
|
||||
stopServiceRemote,
|
||||
updateMongoById,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
apiResetMongo,
|
||||
apiSaveEnvironmentVariablesMongo,
|
||||
apiSaveExternalPortMongo,
|
||||
apiTransferMongo,
|
||||
apiUpdateMongo,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
@@ -637,4 +640,125 @@ export const mongoRouter = createTRPCRouter({
|
||||
mongo.serverId,
|
||||
);
|
||||
}),
|
||||
|
||||
transferScan: protectedProcedure
|
||||
.input(apiTransferMongo.pick({ mongoId: true, targetServerId: true }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mongo = await findMongoById(input.mongoId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mongoId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mongo.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MongoDB",
|
||||
});
|
||||
}
|
||||
return await scanServiceForTransfer({
|
||||
serviceId: input.mongoId,
|
||||
serviceType: "mongo",
|
||||
appName: mongo.appName,
|
||||
sourceServerId: mongo.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
});
|
||||
}),
|
||||
|
||||
transferWithLogs: protectedProcedure
|
||||
.input(apiTransferMongo)
|
||||
.subscription(async function* ({ input, ctx, signal }) {
|
||||
const mongo = await findMongoById(input.mongoId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mongoId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mongo.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MongoDB",
|
||||
});
|
||||
}
|
||||
const queue: string[] = [];
|
||||
let done = false;
|
||||
executeTransfer(
|
||||
{
|
||||
serviceId: input.mongoId,
|
||||
serviceType: "mongo",
|
||||
appName: mongo.appName,
|
||||
sourceServerId: mongo.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
(progress) => { queue.push(JSON.stringify(progress)); },
|
||||
)
|
||||
.then(async (result) => {
|
||||
if (result.success) {
|
||||
await db
|
||||
.update(mongoTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(mongoTable.mongoId, input.mongoId));
|
||||
queue.push("Transfer completed! Starting deployment on target server...");
|
||||
await deployMongo(input.mongoId).catch(() => {});
|
||||
queue.push("Deployment started!");
|
||||
} else {
|
||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
})
|
||||
.finally(() => { done = true; });
|
||||
|
||||
while (!done || queue.length > 0) {
|
||||
if (queue.length > 0) { yield queue.shift()!; }
|
||||
else { await new Promise((r) => setTimeout(r, 50)); }
|
||||
if (signal?.aborted) { return; }
|
||||
}
|
||||
}),
|
||||
|
||||
transfer: protectedProcedure
|
||||
.input(apiTransferMongo)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mongo = await findMongoById(input.mongoId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mongoId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mongo.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MongoDB",
|
||||
});
|
||||
}
|
||||
const result = await executeTransfer(
|
||||
{
|
||||
serviceId: input.mongoId,
|
||||
serviceType: "mongo",
|
||||
appName: mongo.appName,
|
||||
sourceServerId: mongo.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Transfer failed: ${result.errors.join(", ")}`,
|
||||
});
|
||||
}
|
||||
await db
|
||||
.update(mongoTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(mongoTable.mongoId, input.mongoId));
|
||||
|
||||
await deployMongo(input.mongoId).catch(() => {});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
stopServiceRemote,
|
||||
updateMySqlById,
|
||||
getAccessibleServerIds,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
apiResetMysql,
|
||||
apiSaveEnvironmentVariablesMySql,
|
||||
apiSaveExternalPortMySql,
|
||||
apiTransferMysql,
|
||||
apiUpdateMySql,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
@@ -640,4 +643,125 @@ export const mysqlRouter = createTRPCRouter({
|
||||
mysql.serverId,
|
||||
);
|
||||
}),
|
||||
|
||||
transferScan: protectedProcedure
|
||||
.input(apiTransferMysql.pick({ mysqlId: true, targetServerId: true }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mysql = await findMySqlById(input.mysqlId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mysql.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MySQL",
|
||||
});
|
||||
}
|
||||
return await scanServiceForTransfer({
|
||||
serviceId: input.mysqlId,
|
||||
serviceType: "mysql",
|
||||
appName: mysql.appName,
|
||||
sourceServerId: mysql.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
});
|
||||
}),
|
||||
|
||||
transferWithLogs: protectedProcedure
|
||||
.input(apiTransferMysql)
|
||||
.subscription(async function* ({ input, ctx, signal }) {
|
||||
const mysql = await findMySqlById(input.mysqlId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mysql.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MySQL",
|
||||
});
|
||||
}
|
||||
const queue: string[] = [];
|
||||
let done = false;
|
||||
executeTransfer(
|
||||
{
|
||||
serviceId: input.mysqlId,
|
||||
serviceType: "mysql",
|
||||
appName: mysql.appName,
|
||||
sourceServerId: mysql.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
(progress) => { queue.push(JSON.stringify(progress)); },
|
||||
)
|
||||
.then(async (result) => {
|
||||
if (result.success) {
|
||||
await db
|
||||
.update(mysqlTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(mysqlTable.mysqlId, input.mysqlId));
|
||||
queue.push("Transfer completed! Starting deployment on target server...");
|
||||
await deployMySql(input.mysqlId).catch(() => {});
|
||||
queue.push("Deployment started!");
|
||||
} else {
|
||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
})
|
||||
.finally(() => { done = true; });
|
||||
|
||||
while (!done || queue.length > 0) {
|
||||
if (queue.length > 0) { yield queue.shift()!; }
|
||||
else { await new Promise((r) => setTimeout(r, 50)); }
|
||||
if (signal?.aborted) { return; }
|
||||
}
|
||||
}),
|
||||
|
||||
transfer: protectedProcedure
|
||||
.input(apiTransferMysql)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mysql = await findMySqlById(input.mysqlId);
|
||||
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
mysql.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this MySQL",
|
||||
});
|
||||
}
|
||||
const result = await executeTransfer(
|
||||
{
|
||||
serviceId: input.mysqlId,
|
||||
serviceType: "mysql",
|
||||
appName: mysql.appName,
|
||||
sourceServerId: mysql.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Transfer failed: ${result.errors.join(", ")}`,
|
||||
});
|
||||
}
|
||||
await db
|
||||
.update(mysqlTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(mysqlTable.mysqlId, input.mysqlId));
|
||||
|
||||
await deployMySql(input.mysqlId).catch(() => {});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
stopServiceRemote,
|
||||
updatePostgresById,
|
||||
getAccessibleServerIds,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -44,6 +46,7 @@ import {
|
||||
apiResetPostgres,
|
||||
apiSaveEnvironmentVariablesPostgres,
|
||||
apiSaveExternalPortPostgres,
|
||||
apiTransferPostgres,
|
||||
apiUpdatePostgres,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
@@ -650,4 +653,125 @@ export const postgresRouter = createTRPCRouter({
|
||||
postgres.serverId,
|
||||
);
|
||||
}),
|
||||
|
||||
transferScan: protectedProcedure
|
||||
.input(apiTransferPostgres.pick({ postgresId: true, targetServerId: true }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const postgres = await findPostgresById(input.postgresId);
|
||||
await checkServicePermissionAndAccess(ctx, input.postgresId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
postgres.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this Postgres",
|
||||
});
|
||||
}
|
||||
return await scanServiceForTransfer({
|
||||
serviceId: input.postgresId,
|
||||
serviceType: "postgres",
|
||||
appName: postgres.appName,
|
||||
sourceServerId: postgres.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
});
|
||||
}),
|
||||
|
||||
transferWithLogs: protectedProcedure
|
||||
.input(apiTransferPostgres)
|
||||
.subscription(async function* ({ input, ctx, signal }) {
|
||||
const postgres = await findPostgresById(input.postgresId);
|
||||
await checkServicePermissionAndAccess(ctx, input.postgresId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
postgres.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this Postgres",
|
||||
});
|
||||
}
|
||||
const queue: string[] = [];
|
||||
let done = false;
|
||||
executeTransfer(
|
||||
{
|
||||
serviceId: input.postgresId,
|
||||
serviceType: "postgres",
|
||||
appName: postgres.appName,
|
||||
sourceServerId: postgres.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
(progress) => { queue.push(JSON.stringify(progress)); },
|
||||
)
|
||||
.then(async (result) => {
|
||||
if (result.success) {
|
||||
await db
|
||||
.update(postgresTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(postgresTable.postgresId, input.postgresId));
|
||||
queue.push("Transfer completed! Starting deployment on target server...");
|
||||
await deployPostgres(input.postgresId).catch(() => {});
|
||||
queue.push("Deployment started!");
|
||||
} else {
|
||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
})
|
||||
.finally(() => { done = true; });
|
||||
|
||||
while (!done || queue.length > 0) {
|
||||
if (queue.length > 0) { yield queue.shift()!; }
|
||||
else { await new Promise((r) => setTimeout(r, 50)); }
|
||||
if (signal?.aborted) { return; }
|
||||
}
|
||||
}),
|
||||
|
||||
transfer: protectedProcedure
|
||||
.input(apiTransferPostgres)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const postgres = await findPostgresById(input.postgresId);
|
||||
await checkServicePermissionAndAccess(ctx, input.postgresId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
postgres.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this Postgres",
|
||||
});
|
||||
}
|
||||
const result = await executeTransfer(
|
||||
{
|
||||
serviceId: input.postgresId,
|
||||
serviceType: "postgres",
|
||||
appName: postgres.appName,
|
||||
sourceServerId: postgres.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Transfer failed: ${result.errors.join(", ")}`,
|
||||
});
|
||||
}
|
||||
await db
|
||||
.update(postgresTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(postgresTable.postgresId, input.postgresId));
|
||||
|
||||
await deployPostgres(input.postgresId).catch(() => {});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -487,148 +487,6 @@ export const projectRouter = createTRPCRouter({
|
||||
},
|
||||
),
|
||||
|
||||
homeStats: protectedProcedure.query(async ({ ctx }) => {
|
||||
const isPrivileged = ctx.user.role === "owner" || ctx.user.role === "admin";
|
||||
|
||||
let accessedProjects: string[] = [];
|
||||
let accessedEnvironments: string[] = [];
|
||||
let accessedServices: string[] = [];
|
||||
|
||||
if (!isPrivileged) {
|
||||
const member = await findMemberByUserId(
|
||||
ctx.user.id,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
accessedProjects = member.accessedProjects;
|
||||
accessedEnvironments = member.accessedEnvironments;
|
||||
accessedServices = member.accessedServices;
|
||||
|
||||
if (accessedProjects.length === 0) {
|
||||
return {
|
||||
projects: 0,
|
||||
environments: 0,
|
||||
applications: 0,
|
||||
compose: 0,
|
||||
databases: 0,
|
||||
services: 0,
|
||||
status: { running: 0, error: 0, idle: 0 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const projectIdFilter = isPrivileged
|
||||
? eq(projects.organizationId, ctx.session.activeOrganizationId)
|
||||
: and(
|
||||
sql`${projects.projectId} IN (${sql.join(
|
||||
accessedProjects.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
eq(projects.organizationId, ctx.session.activeOrganizationId),
|
||||
);
|
||||
|
||||
const environmentFilter = isPrivileged
|
||||
? undefined
|
||||
: accessedEnvironments.length === 0
|
||||
? sql`false`
|
||||
: sql`${environments.environmentId} IN (${sql.join(
|
||||
accessedEnvironments.map((envId) => sql`${envId}`),
|
||||
sql`, `,
|
||||
)})`;
|
||||
|
||||
const applyFilter = (col: AnyPgColumn) =>
|
||||
isPrivileged ? undefined : buildServiceFilter(col, accessedServices);
|
||||
|
||||
const rows = await db.query.projects.findMany({
|
||||
where: projectIdFilter,
|
||||
columns: { projectId: true },
|
||||
with: {
|
||||
environments: {
|
||||
where: environmentFilter,
|
||||
columns: { environmentId: true },
|
||||
with: {
|
||||
applications: {
|
||||
where: applyFilter(applications.applicationId),
|
||||
columns: { applicationStatus: true },
|
||||
},
|
||||
compose: {
|
||||
where: applyFilter(compose.composeId),
|
||||
columns: { composeStatus: true },
|
||||
},
|
||||
libsql: {
|
||||
where: applyFilter(libsql.libsqlId),
|
||||
columns: { applicationStatus: true },
|
||||
},
|
||||
mariadb: {
|
||||
where: applyFilter(mariadb.mariadbId),
|
||||
columns: { applicationStatus: true },
|
||||
},
|
||||
mongo: {
|
||||
where: applyFilter(mongo.mongoId),
|
||||
columns: { applicationStatus: true },
|
||||
},
|
||||
mysql: {
|
||||
where: applyFilter(mysql.mysqlId),
|
||||
columns: { applicationStatus: true },
|
||||
},
|
||||
postgres: {
|
||||
where: applyFilter(postgres.postgresId),
|
||||
columns: { applicationStatus: true },
|
||||
},
|
||||
redis: {
|
||||
where: applyFilter(redis.redisId),
|
||||
columns: { applicationStatus: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let applicationsCount = 0;
|
||||
let composeCount = 0;
|
||||
let databasesCount = 0;
|
||||
let environmentsCount = 0;
|
||||
const status = { running: 0, error: 0, idle: 0 };
|
||||
const bump = (s?: string | null) => {
|
||||
if (s === "done") status.running++;
|
||||
else if (s === "error") status.error++;
|
||||
else status.idle++;
|
||||
};
|
||||
|
||||
for (const project of rows) {
|
||||
for (const env of project.environments) {
|
||||
environmentsCount++;
|
||||
applicationsCount += env.applications.length;
|
||||
composeCount += env.compose.length;
|
||||
databasesCount +=
|
||||
env.libsql.length +
|
||||
env.mariadb.length +
|
||||
env.mongo.length +
|
||||
env.mysql.length +
|
||||
env.postgres.length +
|
||||
env.redis.length;
|
||||
|
||||
for (const a of env.applications) bump(a.applicationStatus);
|
||||
for (const c of env.compose) bump(c.composeStatus);
|
||||
for (const s of env.libsql) bump(s.applicationStatus);
|
||||
for (const s of env.mariadb) bump(s.applicationStatus);
|
||||
for (const s of env.mongo) bump(s.applicationStatus);
|
||||
for (const s of env.mysql) bump(s.applicationStatus);
|
||||
for (const s of env.postgres) bump(s.applicationStatus);
|
||||
for (const s of env.redis) bump(s.applicationStatus);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projects: rows.length,
|
||||
environments: environmentsCount,
|
||||
applications: applicationsCount,
|
||||
compose: composeCount,
|
||||
databases: databasesCount,
|
||||
services: applicationsCount + composeCount + databasesCount,
|
||||
status,
|
||||
};
|
||||
}),
|
||||
|
||||
search: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { scimProvider } from "@dokploy/server/db/schema";
|
||||
import { requestToHeaders } from "@dokploy/server/index";
|
||||
import { auth } from "@dokploy/server/lib/auth";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, enterpriseProcedure } from "@/server/api/trpc";
|
||||
|
||||
const providerIdSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(
|
||||
/^[a-z0-9][a-z0-9-]*$/,
|
||||
"Provider ID must be lowercase alphanumeric with optional dashes",
|
||||
);
|
||||
|
||||
export const scimRouter = createTRPCRouter({
|
||||
listProviders: enterpriseProcedure.query(async ({ ctx }) => {
|
||||
const providers = await db.query.scimProvider.findMany({
|
||||
where: eq(scimProvider.organizationId, ctx.session.activeOrganizationId),
|
||||
columns: {
|
||||
id: true,
|
||||
providerId: true,
|
||||
organizationId: true,
|
||||
},
|
||||
orderBy: [asc(scimProvider.providerId)],
|
||||
});
|
||||
return providers;
|
||||
}),
|
||||
generateToken: enterpriseProcedure
|
||||
.input(z.object({ providerId: providerIdSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await db.query.scimProvider.findFirst({
|
||||
where: eq(scimProvider.providerId, input.providerId),
|
||||
columns: { id: true, organizationId: true },
|
||||
});
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "A SCIM provider with this ID already exists",
|
||||
});
|
||||
}
|
||||
const result = await auth.generateSCIMToken({
|
||||
body: {
|
||||
providerId: input.providerId,
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
},
|
||||
headers: requestToHeaders(ctx.req),
|
||||
});
|
||||
return { scimToken: result.scimToken, providerId: input.providerId };
|
||||
}),
|
||||
deleteProvider: enterpriseProcedure
|
||||
.input(z.object({ providerId: providerIdSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [deleted] = await db
|
||||
.delete(scimProvider)
|
||||
.where(
|
||||
and(
|
||||
eq(scimProvider.providerId, input.providerId),
|
||||
eq(
|
||||
scimProvider.organizationId,
|
||||
ctx.session.activeOrganizationId,
|
||||
),
|
||||
),
|
||||
)
|
||||
.returning({ id: scimProvider.id });
|
||||
if (!deleted) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message:
|
||||
"SCIM provider not found or you do not have permission to delete it",
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
stopServiceRemote,
|
||||
updateRedisById,
|
||||
getAccessibleServerIds,
|
||||
scanServiceForTransfer,
|
||||
executeTransfer,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
apiResetRedis,
|
||||
apiSaveEnvironmentVariablesRedis,
|
||||
apiSaveExternalPortRedis,
|
||||
apiTransferRedis,
|
||||
apiUpdateRedis,
|
||||
DATABASE_PASSWORD_MESSAGE,
|
||||
DATABASE_PASSWORD_REGEX,
|
||||
@@ -623,4 +626,125 @@ export const redisRouter = createTRPCRouter({
|
||||
redis.serverId,
|
||||
);
|
||||
}),
|
||||
|
||||
transferScan: protectedProcedure
|
||||
.input(apiTransferRedis.pick({ redisId: true, targetServerId: true }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const redis = await findRedisById(input.redisId);
|
||||
await checkServicePermissionAndAccess(ctx, input.redisId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
redis.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this Redis",
|
||||
});
|
||||
}
|
||||
return await scanServiceForTransfer({
|
||||
serviceId: input.redisId,
|
||||
serviceType: "redis",
|
||||
appName: redis.appName,
|
||||
sourceServerId: redis.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
});
|
||||
}),
|
||||
|
||||
transferWithLogs: protectedProcedure
|
||||
.input(apiTransferRedis)
|
||||
.subscription(async function* ({ input, ctx, signal }) {
|
||||
const redis = await findRedisById(input.redisId);
|
||||
await checkServicePermissionAndAccess(ctx, input.redisId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
redis.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this Redis",
|
||||
});
|
||||
}
|
||||
const queue: string[] = [];
|
||||
let done = false;
|
||||
executeTransfer(
|
||||
{
|
||||
serviceId: input.redisId,
|
||||
serviceType: "redis",
|
||||
appName: redis.appName,
|
||||
sourceServerId: redis.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
(progress) => { queue.push(JSON.stringify(progress)); },
|
||||
)
|
||||
.then(async (result) => {
|
||||
if (result.success) {
|
||||
await db
|
||||
.update(redisTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(redisTable.redisId, input.redisId));
|
||||
queue.push("Transfer completed! Starting deployment on target server...");
|
||||
await deployRedis(input.redisId).catch(() => {});
|
||||
queue.push("Deployment started!");
|
||||
} else {
|
||||
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
})
|
||||
.finally(() => { done = true; });
|
||||
|
||||
while (!done || queue.length > 0) {
|
||||
if (queue.length > 0) { yield queue.shift()!; }
|
||||
else { await new Promise((r) => setTimeout(r, 50)); }
|
||||
if (signal?.aborted) { return; }
|
||||
}
|
||||
}),
|
||||
|
||||
transfer: protectedProcedure
|
||||
.input(apiTransferRedis)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const redis = await findRedisById(input.redisId);
|
||||
await checkServicePermissionAndAccess(ctx, input.redisId, {
|
||||
service: ["delete"],
|
||||
});
|
||||
if (
|
||||
redis.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this Redis",
|
||||
});
|
||||
}
|
||||
const result = await executeTransfer(
|
||||
{
|
||||
serviceId: input.redisId,
|
||||
serviceType: "redis",
|
||||
appName: redis.appName,
|
||||
sourceServerId: redis.serverId,
|
||||
targetServerId: input.targetServerId,
|
||||
},
|
||||
input.decisions || {},
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Transfer failed: ${result.errors.join(", ")}`,
|
||||
});
|
||||
}
|
||||
await db
|
||||
.update(redisTable)
|
||||
.set({ serverId: input.targetServerId })
|
||||
.where(eq(redisTable.redisId, input.redisId));
|
||||
|
||||
await deployRedis(input.redisId).catch(() => {});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,311 +1,299 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
boolean,
|
||||
integer,
|
||||
index,
|
||||
uniqueIndex,
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const user = pgTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
firstName: text("first_name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
||||
role: text("role"),
|
||||
banned: boolean("banned").default(false),
|
||||
banReason: text("ban_reason"),
|
||||
banExpires: timestamp("ban_expires"),
|
||||
ownerId: text("owner_id"),
|
||||
allowImpersonation: boolean("allow_impersonation").default(false),
|
||||
lastName: text("last_name").default(""),
|
||||
enableEnterpriseFeatures: boolean("enable_enterprise_features"),
|
||||
isValidEnterpriseLicense: boolean("is_valid_enterprise_license"),
|
||||
id: text("id").primaryKey(),
|
||||
firstName: text("first_name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||
image: text("image"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
||||
role: text("role"),
|
||||
ownerId: text("owner_id"),
|
||||
allowImpersonation: boolean("allow_impersonation").default(false),
|
||||
lastName: text("last_name").default(""),
|
||||
enableEnterpriseFeatures: boolean("enable_enterprise_features"),
|
||||
isValidEnterpriseLicense: boolean("is_valid_enterprise_license"),
|
||||
});
|
||||
|
||||
export const session = pgTable(
|
||||
"session",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
activeOrganizationId: text("active_organization_id"),
|
||||
impersonatedBy: text("impersonated_by"),
|
||||
},
|
||||
(table) => [index("session_userId_idx").on(table.userId)],
|
||||
"session",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
activeOrganizationId: text("active_organization_id"),
|
||||
},
|
||||
(table) => [index("session_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
export const account = pgTable(
|
||||
"account",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("account_userId_idx").on(table.userId)],
|
||||
"account",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("account_userId_idx").on(table.userId)],
|
||||
);
|
||||
|
||||
export const verification = pgTable(
|
||||
"verification",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||
"verification",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||
);
|
||||
|
||||
export const apikey = pgTable(
|
||||
"apikey",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
configId: text("config_id").default("default").notNull(),
|
||||
name: text("name"),
|
||||
start: text("start"),
|
||||
referenceId: text("reference_id").notNull(),
|
||||
prefix: text("prefix"),
|
||||
key: text("key").notNull(),
|
||||
refillInterval: integer("refill_interval"),
|
||||
refillAmount: integer("refill_amount"),
|
||||
lastRefillAt: timestamp("last_refill_at"),
|
||||
enabled: boolean("enabled").default(true),
|
||||
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
|
||||
rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
|
||||
rateLimitMax: integer("rate_limit_max").default(10),
|
||||
requestCount: integer("request_count").default(0),
|
||||
remaining: integer("remaining"),
|
||||
lastRequest: timestamp("last_request"),
|
||||
expiresAt: timestamp("expires_at"),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
updatedAt: timestamp("updated_at").notNull(),
|
||||
permissions: text("permissions"),
|
||||
metadata: text("metadata"),
|
||||
},
|
||||
(table) => [
|
||||
index("apikey_configId_idx").on(table.configId),
|
||||
index("apikey_referenceId_idx").on(table.referenceId),
|
||||
index("apikey_key_idx").on(table.key),
|
||||
],
|
||||
"apikey",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
configId: text("config_id").default("default").notNull(),
|
||||
name: text("name"),
|
||||
start: text("start"),
|
||||
referenceId: text("reference_id").notNull(),
|
||||
prefix: text("prefix"),
|
||||
key: text("key").notNull(),
|
||||
refillInterval: integer("refill_interval"),
|
||||
refillAmount: integer("refill_amount"),
|
||||
lastRefillAt: timestamp("last_refill_at"),
|
||||
enabled: boolean("enabled").default(true),
|
||||
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
|
||||
rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
|
||||
rateLimitMax: integer("rate_limit_max").default(10),
|
||||
requestCount: integer("request_count").default(0),
|
||||
remaining: integer("remaining"),
|
||||
lastRequest: timestamp("last_request"),
|
||||
expiresAt: timestamp("expires_at"),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
updatedAt: timestamp("updated_at").notNull(),
|
||||
permissions: text("permissions"),
|
||||
metadata: text("metadata"),
|
||||
},
|
||||
(table) => [
|
||||
index("apikey_configId_idx").on(table.configId),
|
||||
index("apikey_referenceId_idx").on(table.referenceId),
|
||||
index("apikey_key_idx").on(table.key),
|
||||
],
|
||||
);
|
||||
|
||||
export const ssoProvider = pgTable("sso_provider", {
|
||||
id: text("id").primaryKey(),
|
||||
issuer: text("issuer").notNull(),
|
||||
oidcConfig: text("oidc_config"),
|
||||
samlConfig: text("saml_config"),
|
||||
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
|
||||
providerId: text("provider_id").notNull().unique(),
|
||||
organizationId: text("organization_id"),
|
||||
domain: text("domain").notNull(),
|
||||
id: text("id").primaryKey(),
|
||||
issuer: text("issuer").notNull(),
|
||||
oidcConfig: text("oidc_config"),
|
||||
samlConfig: text("saml_config"),
|
||||
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
|
||||
providerId: text("provider_id").notNull().unique(),
|
||||
organizationId: text("organization_id"),
|
||||
domain: text("domain").notNull(),
|
||||
});
|
||||
|
||||
export const twoFactor = pgTable(
|
||||
"two_factor",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
secret: text("secret").notNull(),
|
||||
backupCodes: text("backup_codes").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
verified: boolean("verified").default(true),
|
||||
},
|
||||
(table) => [
|
||||
index("twoFactor_secret_idx").on(table.secret),
|
||||
index("twoFactor_userId_idx").on(table.userId),
|
||||
],
|
||||
"two_factor",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
secret: text("secret").notNull(),
|
||||
backupCodes: text("backup_codes").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
index("twoFactor_secret_idx").on(table.secret),
|
||||
index("twoFactor_userId_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const organization = pgTable(
|
||||
"organization",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
logo: text("logo"),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
metadata: text("metadata"),
|
||||
},
|
||||
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
|
||||
"organization",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
logo: text("logo"),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
metadata: text("metadata"),
|
||||
},
|
||||
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
|
||||
);
|
||||
|
||||
export const organizationRole = pgTable(
|
||||
"organization_role",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
role: text("role").notNull(),
|
||||
permission: text("permission").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").$onUpdate(
|
||||
() => /* @__PURE__ */ new Date(),
|
||||
),
|
||||
},
|
||||
(table) => [
|
||||
index("organizationRole_organizationId_idx").on(table.organizationId),
|
||||
index("organizationRole_role_idx").on(table.role),
|
||||
],
|
||||
"organization_role",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
role: text("role").notNull(),
|
||||
permission: text("permission").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").$onUpdate(
|
||||
() => /* @__PURE__ */ new Date(),
|
||||
),
|
||||
},
|
||||
(table) => [
|
||||
index("organizationRole_organizationId_idx").on(table.organizationId),
|
||||
index("organizationRole_role_idx").on(table.role),
|
||||
],
|
||||
);
|
||||
|
||||
export const member = pgTable(
|
||||
"member",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
role: text("role").default("member").notNull(),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("member_organizationId_idx").on(table.organizationId),
|
||||
index("member_userId_idx").on(table.userId),
|
||||
],
|
||||
"member",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
role: text("role").default("member").notNull(),
|
||||
createdAt: timestamp("created_at").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("member_organizationId_idx").on(table.organizationId),
|
||||
index("member_userId_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export const invitation = pgTable(
|
||||
"invitation",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
role: text("role"),
|
||||
status: text("status").default("pending").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
inviterId: text("inviter_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
index("invitation_organizationId_idx").on(table.organizationId),
|
||||
index("invitation_email_idx").on(table.email),
|
||||
],
|
||||
"invitation",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
role: text("role"),
|
||||
status: text("status").default("pending").notNull(),
|
||||
expiresAt: timestamp("expires_at").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
inviterId: text("inviter_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
index("invitation_organizationId_idx").on(table.organizationId),
|
||||
index("invitation_email_idx").on(table.email),
|
||||
],
|
||||
);
|
||||
|
||||
export const scimProvider = pgTable("scim_provider", {
|
||||
id: text("id").primaryKey(),
|
||||
providerId: text("provider_id").notNull().unique(),
|
||||
scimToken: text("scim_token").notNull().unique(),
|
||||
organizationId: text("organization_id"),
|
||||
});
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
ssoProviders: many(ssoProvider),
|
||||
twoFactors: many(twoFactor),
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
ssoProviders: many(ssoProvider),
|
||||
twoFactors: many(twoFactor),
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
}));
|
||||
|
||||
export const sessionRelations = relations(session, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [session.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [session.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const accountRelations = relations(account, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [account.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [account.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [ssoProvider.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [ssoProvider.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [twoFactor.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [twoFactor.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const organizationRelations = relations(organization, ({ many }) => ({
|
||||
organizationRoles: many(organizationRole),
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
organizationRoles: many(organizationRole),
|
||||
members: many(member),
|
||||
invitations: many(invitation),
|
||||
}));
|
||||
|
||||
export const organizationRoleRelations = relations(
|
||||
organizationRole,
|
||||
({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [organizationRole.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
}),
|
||||
organizationRole,
|
||||
({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [organizationRole.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const memberRelations = relations(member, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [member.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [member.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
organization: one(organization, {
|
||||
fields: [member.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [member.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const invitationRelations = relations(invitation, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [invitation.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [invitation.inviterId],
|
||||
references: [user.id],
|
||||
}),
|
||||
organization: one(organization, {
|
||||
fields: [invitation.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [invitation.inviterId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -37,10 +37,9 @@
|
||||
"@ai-sdk/mistral": "^3.0.20",
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@better-auth/api-key": "1.6.5",
|
||||
"@better-auth/scim": "^1.6.5",
|
||||
"@better-auth/sso": "1.6.5",
|
||||
"@better-auth/utils": "0.4.0",
|
||||
"@better-auth/api-key": "1.5.4",
|
||||
"@better-auth/sso": "1.5.4",
|
||||
"@better-auth/utils": "0.3.1",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@octokit/rest": "^20.1.2",
|
||||
@@ -52,14 +51,15 @@
|
||||
"ai": "^6.0.86",
|
||||
"ai-sdk-ollama": "^3.7.0",
|
||||
"bcrypt": "5.1.1",
|
||||
"better-auth": "1.6.5",
|
||||
"better-auth": "1.5.4",
|
||||
"better-call": "2.0.2",
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
"date-fns": "3.6.0",
|
||||
"dockerode": "4.0.2",
|
||||
"dotenv": "16.4.5",
|
||||
"drizzle-dbml-generator": "0.10.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"drizzle-zod": "0.5.1",
|
||||
"lodash": "4.17.21",
|
||||
"micromatch": "4.0.8",
|
||||
|
||||
@@ -214,7 +214,6 @@ export const twoFactor = pgTable("two_factor", {
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
verified: boolean("verified").notNull().default(true),
|
||||
});
|
||||
|
||||
export const apikey = pgTable("apikey", {
|
||||
|
||||
@@ -534,3 +534,9 @@ export const apiUpdateApplication = createSchema
|
||||
applicationId: z.string().min(1),
|
||||
})
|
||||
.omit({ serverId: true });
|
||||
|
||||
export const apiTransferApplication = z.object({
|
||||
applicationId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -240,3 +240,9 @@ export const apiRandomizeCompose = createSchema
|
||||
suffix: z.string().optional(),
|
||||
composeId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiTransferCompose = z.object({
|
||||
composeId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -30,7 +30,6 @@ export * from "./redis";
|
||||
export * from "./registry";
|
||||
export * from "./rollbacks";
|
||||
export * from "./schedule";
|
||||
export * from "./scim";
|
||||
export * from "./security";
|
||||
export * from "./server";
|
||||
export * from "./session";
|
||||
|
||||
@@ -213,3 +213,9 @@ export const apiRebuildMariadb = createSchema
|
||||
mariadbId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiTransferMariadb = z.object({
|
||||
mariadbId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -210,3 +210,9 @@ export const apiRebuildMongo = createSchema
|
||||
mongoId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiTransferMongo = z.object({
|
||||
mongoId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -210,3 +210,9 @@ export const apiRebuildMysql = createSchema
|
||||
mysqlId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiTransferMysql = z.object({
|
||||
mysqlId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -204,3 +204,9 @@ export const apiRebuildPostgres = createSchema
|
||||
postgresId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiTransferPostgres = z.object({
|
||||
postgresId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -187,3 +187,9 @@ export const apiRebuildRedis = createSchema
|
||||
redisId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiTransferRedis = z.object({
|
||||
redisId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { nanoid } from "nanoid";
|
||||
import { organization } from "./account";
|
||||
|
||||
export const scimProvider = pgTable("scim_provider", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
providerId: text("provider_id").notNull().unique(),
|
||||
scimToken: text("scim_token").notNull().unique(),
|
||||
organizationId: text("organization_id").references(() => organization.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
});
|
||||
|
||||
export const scimProviderRelations = relations(scimProvider, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [scimProvider.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
}));
|
||||
@@ -1,104 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
export type TemplateProps = {
|
||||
userName: string;
|
||||
verificationUrl: string;
|
||||
};
|
||||
|
||||
export const VerifyEmailTemplate = ({
|
||||
userName = "User",
|
||||
verificationUrl = "https://app.dokploy.com/verify",
|
||||
}: TemplateProps) => {
|
||||
const previewText = "Verify your email address to get started with Dokploy";
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: "#007291",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
|
||||
<Container className="my-[40px] mx-auto max-w-[520px]">
|
||||
{/* Header */}
|
||||
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
|
||||
<Img
|
||||
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
|
||||
width="190"
|
||||
height="120"
|
||||
alt="Dokploy"
|
||||
className="my-0 mx-auto"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Body */}
|
||||
<Section className="bg-white px-[40px] py-[32px]">
|
||||
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
|
||||
Verify Your Email
|
||||
</Heading>
|
||||
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
|
||||
Hello {userName}, thank you for signing up for Dokploy. Please
|
||||
verify your email address to activate your account.
|
||||
</Text>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Section className="text-center mb-[24px]">
|
||||
<Button
|
||||
href={verificationUrl}
|
||||
className="bg-[#09090b] rounded-lg text-white text-[14px] font-semibold no-underline text-center px-[24px] py-[12px]"
|
||||
>
|
||||
Verify Email Address
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[#a1a1aa] text-[13px] leading-[20px] m-0 text-center mb-[16px]">
|
||||
If the button above doesn't work, copy and paste the following
|
||||
link into your browser:
|
||||
</Text>
|
||||
<Text className="text-[#71717a] text-[12px] leading-[18px] m-0 text-center break-all">
|
||||
{verificationUrl}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* Footer */}
|
||||
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
|
||||
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
|
||||
This is an automated email from{" "}
|
||||
<Link
|
||||
href="https://dokploy.com"
|
||||
className="text-[#71717a] underline"
|
||||
>
|
||||
Dokploy Cloud
|
||||
</Link>
|
||||
. If you didn't create an account, you can safely ignore this
|
||||
email.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifyEmailTemplate;
|
||||
@@ -47,6 +47,7 @@ export * from "./services/server";
|
||||
export * from "./services/settings";
|
||||
export * from "./services/ssh-key";
|
||||
export * from "./services/user";
|
||||
export * from "./services/transfer";
|
||||
export * from "./services/volume-backups";
|
||||
export * from "./services/web-server-settings";
|
||||
export * from "./setup/config-paths";
|
||||
@@ -131,6 +132,7 @@ export * from "./utils/traefik/redirect";
|
||||
export * from "./utils/traefik/security";
|
||||
export * from "./utils/traefik/types";
|
||||
export * from "./utils/traefik/web-server";
|
||||
export * from "./utils/transfer/index";
|
||||
export * from "./utils/volume-backups/index";
|
||||
export * from "./utils/watch-paths/should-deploy";
|
||||
export * from "./wss/utils";
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { apiKey } from "@better-auth/api-key";
|
||||
import { scim } from "@better-auth/scim";
|
||||
import { sso } from "@better-auth/sso";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { admin, organization, twoFactor } from "better-auth/plugins";
|
||||
import { db } from "../db";
|
||||
import * as schema from "../db/schema";
|
||||
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
|
||||
|
||||
/**
|
||||
* Minimal better-auth config used only by `@better-auth/cli` to generate /
|
||||
* inspect database schemas. Must mirror the plugin set in `auth.ts` so the CLI
|
||||
* sees every table each plugin expects.
|
||||
*
|
||||
* Do NOT import this file from the runtime — use `auth.ts` for that.
|
||||
*/
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema,
|
||||
}),
|
||||
user: {
|
||||
modelName: "user",
|
||||
fields: {
|
||||
name: "firstName",
|
||||
},
|
||||
additionalFields: {
|
||||
role: { type: "string", input: false },
|
||||
ownerId: { type: "string", input: false },
|
||||
allowImpersonation: { type: "boolean", defaultValue: false },
|
||||
lastName: { type: "string", required: false, defaultValue: "" },
|
||||
enableEnterpriseFeatures: { type: "boolean", required: false },
|
||||
isValidEnterpriseLicense: { type: "boolean", required: false },
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
apiKey({ enableMetadata: true, references: "user" }),
|
||||
sso(),
|
||||
twoFactor(),
|
||||
organization({
|
||||
ac,
|
||||
roles: { owner: ownerRole, admin: adminRole, member: memberRole },
|
||||
dynamicAccessControl: {
|
||||
enabled: true,
|
||||
maximumRolesPerOrganization: 10,
|
||||
},
|
||||
}),
|
||||
scim(),
|
||||
admin(),
|
||||
],
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { apiKey } from "@better-auth/api-key";
|
||||
import { scim } from "@better-auth/scim";
|
||||
import { sso } from "@better-auth/sso";
|
||||
import * as bcrypt from "bcrypt";
|
||||
import { betterAuth } from "better-auth";
|
||||
@@ -22,10 +21,7 @@ import {
|
||||
updateWebServerSettings,
|
||||
} from "../services/web-server-settings";
|
||||
import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
|
||||
import {
|
||||
sendEmail,
|
||||
sendVerificationEmail,
|
||||
} from "../verification/send-verification-email";
|
||||
import { sendEmail } from "../verification/send-verification-email";
|
||||
import { getPublicIpWithFallback } from "../wss/utils";
|
||||
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
|
||||
|
||||
@@ -110,13 +106,14 @@ const { handler, api } = betterAuth({
|
||||
emailVerification: {
|
||||
sendOnSignUp: true,
|
||||
autoSignInAfterVerification: true,
|
||||
sendOnSignIn: true,
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
if (IS_CLOUD) {
|
||||
await sendVerificationEmail({
|
||||
userName: user.name || "User",
|
||||
await sendEmail({
|
||||
email: user.email,
|
||||
verificationUrl: url,
|
||||
subject: "Verify your email",
|
||||
text: `
|
||||
<p>Click the link to verify your email: <a href="${url}">Verify Email</a></p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -179,8 +176,7 @@ const { handler, api } = betterAuth({
|
||||
}
|
||||
} else {
|
||||
const isSSORequest = context?.path.includes("/sso");
|
||||
const isSCIMRequest = context?.path.includes("/scim");
|
||||
if (isSSORequest || isSCIMRequest) {
|
||||
if (isSSORequest) {
|
||||
return;
|
||||
}
|
||||
const isAdminPresent = await db.query.member.findFirst({
|
||||
@@ -196,7 +192,6 @@ const { handler, api } = betterAuth({
|
||||
},
|
||||
after: async (user, context) => {
|
||||
const isSSORequest = context?.path.includes("/sso");
|
||||
const isSCIMRequest = context?.path.includes("/scim");
|
||||
const isAdminPresent = await db.query.member.findFirst({
|
||||
where: eq(schema.member.role, "owner"),
|
||||
});
|
||||
@@ -232,10 +227,6 @@ const { handler, api } = betterAuth({
|
||||
}
|
||||
}
|
||||
|
||||
if (isSCIMRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (IS_CLOUD || !isAdminPresent) {
|
||||
await db.transaction(async (tx) => {
|
||||
const organization = await tx
|
||||
@@ -403,24 +394,7 @@ const { handler, api } = betterAuth({
|
||||
enableMetadata: true,
|
||||
references: "user",
|
||||
}),
|
||||
sso({
|
||||
saml: {
|
||||
enableInResponseToValidation: false,
|
||||
},
|
||||
}),
|
||||
scim({
|
||||
beforeSCIMTokenGenerated: async ({ user }) => {
|
||||
const dbUser = await db.query.user.findFirst({
|
||||
where: eq(schema.user.id, user.id),
|
||||
columns: { enableEnterpriseFeatures: true },
|
||||
});
|
||||
if (!dbUser?.enableEnterpriseFeatures) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: "SCIM provisioning requires an enterprise license",
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
sso(),
|
||||
twoFactor(),
|
||||
organization({
|
||||
ac,
|
||||
@@ -466,9 +440,6 @@ const _auth = {
|
||||
createApiKey: api.createApiKey,
|
||||
registerSSOProvider: api.registerSSOProvider,
|
||||
updateSSOProvider: api.updateSSOProvider,
|
||||
generateSCIMToken: api.generateSCIMToken,
|
||||
listSCIMProviderConnections: api.listSCIMProviderConnections,
|
||||
deleteSCIMProviderConnection: api.deleteSCIMProviderConnection,
|
||||
};
|
||||
|
||||
export type AuthType = typeof _auth;
|
||||
|
||||
456
packages/server/src/services/transfer.ts
Normal file
456
packages/server/src/services/transfer.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import path from "node:path";
|
||||
import { findMountsByApplicationId } from "./mount";
|
||||
import {
|
||||
compareFileLists,
|
||||
getDirectorySize,
|
||||
getVolumeSize,
|
||||
listComposeVolumes,
|
||||
listVolumesByPrefix,
|
||||
scanDirectory,
|
||||
scanDockerVolume,
|
||||
scanMount,
|
||||
} from "../utils/transfer/scanner";
|
||||
import { runPreflightChecks } from "../utils/transfer/preflight";
|
||||
import {
|
||||
syncDirectory,
|
||||
syncDockerVolume,
|
||||
syncMount,
|
||||
syncTraefikConfig,
|
||||
} from "../utils/transfer/sync";
|
||||
import type {
|
||||
ConflictDecision,
|
||||
MountTransferConfig,
|
||||
ServiceType,
|
||||
TransferOptions,
|
||||
TransferProgress,
|
||||
TransferResult,
|
||||
TransferScanResult,
|
||||
} from "../utils/transfer/types";
|
||||
|
||||
const getServiceBasePath = (
|
||||
serviceType: ServiceType,
|
||||
appName: string,
|
||||
isRemote: boolean,
|
||||
): string => {
|
||||
if (serviceType === "compose") {
|
||||
const { COMPOSE_PATH } = paths(isRemote);
|
||||
return path.join(COMPOSE_PATH, appName);
|
||||
}
|
||||
const { APPLICATIONS_PATH } = paths(isRemote);
|
||||
return path.join(APPLICATIONS_PATH, appName);
|
||||
};
|
||||
|
||||
const hasServiceDirectory = (serviceType: ServiceType): boolean => {
|
||||
return serviceType === "application" || serviceType === "compose";
|
||||
};
|
||||
|
||||
const getAutoDataVolumeName = (
|
||||
serviceType: ServiceType,
|
||||
appName: string,
|
||||
): string | null => {
|
||||
const dbTypes: ServiceType[] = [
|
||||
"postgres",
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"mongo",
|
||||
"redis",
|
||||
];
|
||||
if (dbTypes.includes(serviceType)) {
|
||||
return `${appName}-data`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Discover all Docker volumes for a service.
|
||||
* For compose: uses Docker labels + prefix matching.
|
||||
* For databases: uses the auto {appName}-data convention.
|
||||
* For applications: uses user-defined mounts only.
|
||||
*/
|
||||
const discoverServiceVolumes = async (
|
||||
serverId: string | null,
|
||||
serviceType: ServiceType,
|
||||
appName: string,
|
||||
): Promise<string[]> => {
|
||||
const volumes: Set<string> = new Set();
|
||||
|
||||
if (serviceType === "compose") {
|
||||
// Get volumes by compose project label
|
||||
const labelVolumes = await listComposeVolumes(serverId, appName);
|
||||
for (const v of labelVolumes) {
|
||||
volumes.add(v);
|
||||
}
|
||||
|
||||
// Also try prefix matching (compose uses {projectName}_{volumeName} pattern)
|
||||
const prefixVolumes = await listVolumesByPrefix(serverId, `${appName}_`);
|
||||
for (const v of prefixVolumes) {
|
||||
volumes.add(v);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto data volume for databases
|
||||
const autoVolume = getAutoDataVolumeName(serviceType, appName);
|
||||
if (autoVolume) {
|
||||
volumes.add(autoVolume);
|
||||
}
|
||||
|
||||
return Array.from(volumes);
|
||||
};
|
||||
|
||||
export const scanServiceForTransfer = async (
|
||||
opts: TransferOptions,
|
||||
): Promise<TransferScanResult> => {
|
||||
const { serviceType, appName, sourceServerId, targetServerId } = opts;
|
||||
|
||||
const result: TransferScanResult = {
|
||||
serviceDirectory: { files: [], totalSize: 0 },
|
||||
traefikConfig: { exists: false, hasConflict: false },
|
||||
mounts: [],
|
||||
totalTransferSize: 0,
|
||||
totalFiles: 0,
|
||||
conflicts: [],
|
||||
};
|
||||
|
||||
// 1. Scan service directory (application/compose only)
|
||||
if (hasServiceDirectory(serviceType)) {
|
||||
const sourcePath = getServiceBasePath(
|
||||
serviceType,
|
||||
appName,
|
||||
!!sourceServerId,
|
||||
);
|
||||
const targetPath = getServiceBasePath(serviceType, appName, true);
|
||||
|
||||
const sourceFiles = await scanDirectory(sourceServerId, sourcePath);
|
||||
const targetFiles = await scanDirectory(targetServerId, targetPath);
|
||||
const dirSize = await getDirectorySize(sourceServerId, sourcePath);
|
||||
|
||||
const fileConflicts = compareFileLists(sourceFiles, targetFiles);
|
||||
|
||||
result.serviceDirectory = {
|
||||
files: fileConflicts,
|
||||
totalSize: dirSize || sourceFiles.reduce((sum, f) => sum + f.size, 0),
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Check Traefik config
|
||||
if (serviceType === "application" || serviceType === "compose") {
|
||||
const { DYNAMIC_TRAEFIK_PATH } = paths(!!sourceServerId);
|
||||
const configFile = `${appName}.yml`;
|
||||
const sourceConfigFiles = await scanDirectory(
|
||||
sourceServerId,
|
||||
DYNAMIC_TRAEFIK_PATH,
|
||||
);
|
||||
const hasSourceConfig = sourceConfigFiles.some(
|
||||
(f) => f.path === configFile,
|
||||
);
|
||||
|
||||
if (hasSourceConfig) {
|
||||
result.traefikConfig.exists = true;
|
||||
const { DYNAMIC_TRAEFIK_PATH: targetTraefikPath } = paths(true);
|
||||
const targetConfigFiles = await scanDirectory(
|
||||
targetServerId,
|
||||
targetTraefikPath,
|
||||
);
|
||||
result.traefikConfig.hasConflict = targetConfigFiles.some(
|
||||
(f) => f.path === configFile,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Discover and scan ALL Docker volumes for the service
|
||||
const discoveredVolumes = await discoverServiceVolumes(
|
||||
sourceServerId,
|
||||
serviceType,
|
||||
appName,
|
||||
);
|
||||
|
||||
for (const volumeName of discoveredVolumes) {
|
||||
const sourceFiles = await scanDockerVolume(sourceServerId, volumeName);
|
||||
const targetFiles = await scanDockerVolume(targetServerId, volumeName);
|
||||
const volSize = await getVolumeSize(sourceServerId, volumeName);
|
||||
|
||||
const fileConflicts = compareFileLists(sourceFiles, targetFiles);
|
||||
|
||||
result.mounts.push({
|
||||
mount: {
|
||||
mountId: `docker-${volumeName}`,
|
||||
type: "volume",
|
||||
volumeName,
|
||||
mountPath: "/data",
|
||||
},
|
||||
files: fileConflicts,
|
||||
totalSize: volSize || sourceFiles.reduce((sum, f) => sum + f.size, 0),
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Scan user-defined mounts from Dokploy DB
|
||||
const serviceTypeForMount = serviceType as
|
||||
| "application"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "compose";
|
||||
|
||||
const userMounts = await findMountsByApplicationId(
|
||||
opts.serviceId,
|
||||
serviceTypeForMount,
|
||||
);
|
||||
|
||||
for (const mount of userMounts) {
|
||||
if (mount.type === "file") continue;
|
||||
|
||||
// Skip if already discovered as Docker volume
|
||||
if (
|
||||
mount.type === "volume" &&
|
||||
mount.volumeName &&
|
||||
discoveredVolumes.includes(mount.volumeName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mountConfig: MountTransferConfig = {
|
||||
mountId: mount.mountId,
|
||||
type: mount.type,
|
||||
hostPath: mount.hostPath,
|
||||
volumeName: mount.volumeName,
|
||||
mountPath: mount.mountPath,
|
||||
content: mount.content,
|
||||
filePath: mount.filePath,
|
||||
};
|
||||
|
||||
const sourceFiles = await scanMount(sourceServerId, mountConfig);
|
||||
const targetFiles = await scanMount(targetServerId, mountConfig);
|
||||
|
||||
let mountSize = 0;
|
||||
if (mount.type === "volume" && mount.volumeName) {
|
||||
mountSize = await getVolumeSize(sourceServerId, mount.volumeName);
|
||||
} else if (mount.type === "bind" && mount.hostPath) {
|
||||
mountSize = await getDirectorySize(sourceServerId, mount.hostPath);
|
||||
}
|
||||
|
||||
const fileConflicts = compareFileLists(sourceFiles, targetFiles);
|
||||
|
||||
result.mounts.push({
|
||||
mount: mountConfig,
|
||||
files: fileConflicts,
|
||||
totalSize: mountSize || sourceFiles.reduce((sum, f) => sum + f.size, 0),
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
result.totalTransferSize =
|
||||
result.serviceDirectory.totalSize +
|
||||
result.mounts.reduce((sum, m) => sum + m.totalSize, 0);
|
||||
|
||||
result.totalFiles =
|
||||
result.serviceDirectory.files.length +
|
||||
result.mounts.reduce((sum, m) => sum + m.files.length, 0);
|
||||
|
||||
result.conflicts = [
|
||||
...result.serviceDirectory.files,
|
||||
...result.mounts.flatMap((m) => m.files),
|
||||
].filter((f) => f.status !== "match" && f.status !== "missing_target");
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const executeTransfer = async (
|
||||
opts: TransferOptions,
|
||||
decisions: Record<string, ConflictDecision>,
|
||||
onProgress?: (progress: TransferProgress) => void,
|
||||
): Promise<TransferResult> => {
|
||||
const { serviceType, appName, sourceServerId, targetServerId } = opts;
|
||||
const errors: string[] = [];
|
||||
const processedFiles = 0;
|
||||
const transferredBytes = 0;
|
||||
|
||||
const reportProgress = (
|
||||
phase: TransferProgress["phase"],
|
||||
message?: string,
|
||||
currentFile?: string,
|
||||
) => {
|
||||
onProgress?.({
|
||||
phase,
|
||||
currentFile,
|
||||
processedFiles,
|
||||
totalFiles: 0,
|
||||
transferredBytes,
|
||||
totalBytes: 0,
|
||||
percentage: 0,
|
||||
message,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
// Phase 1: Preflight
|
||||
reportProgress("preparing", "Running preflight checks...");
|
||||
|
||||
// Discover all volumes
|
||||
const discoveredVolumes = await discoverServiceVolumes(
|
||||
sourceServerId,
|
||||
serviceType,
|
||||
appName,
|
||||
);
|
||||
|
||||
// User-defined mounts
|
||||
const mountConfigs: MountTransferConfig[] = [];
|
||||
const serviceTypeForMount = serviceType as
|
||||
| "application"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "compose";
|
||||
|
||||
const userMounts = await findMountsByApplicationId(
|
||||
opts.serviceId,
|
||||
serviceTypeForMount,
|
||||
);
|
||||
|
||||
for (const mount of userMounts) {
|
||||
if (mount.type === "file") continue;
|
||||
if (
|
||||
mount.type === "volume" &&
|
||||
mount.volumeName &&
|
||||
discoveredVolumes.includes(mount.volumeName)
|
||||
) {
|
||||
continue; // Will be handled as discovered volume
|
||||
}
|
||||
mountConfigs.push({
|
||||
mountId: mount.mountId,
|
||||
type: mount.type,
|
||||
hostPath: mount.hostPath,
|
||||
volumeName: mount.volumeName,
|
||||
mountPath: mount.mountPath,
|
||||
content: mount.content,
|
||||
filePath: mount.filePath,
|
||||
});
|
||||
}
|
||||
|
||||
const allVolumeConfigs: MountTransferConfig[] = [
|
||||
...discoveredVolumes.map((v) => ({
|
||||
mountId: `docker-${v}`,
|
||||
type: "volume" as const,
|
||||
volumeName: v,
|
||||
mountPath: "/data",
|
||||
})),
|
||||
...mountConfigs,
|
||||
];
|
||||
|
||||
const targetBasePath = getServiceBasePath(serviceType, appName, true);
|
||||
|
||||
const preflight = await runPreflightChecks(
|
||||
targetServerId,
|
||||
targetBasePath,
|
||||
0,
|
||||
allVolumeConfigs,
|
||||
(msg) => reportProgress("preparing", msg),
|
||||
);
|
||||
|
||||
if (!preflight.passed) {
|
||||
return { success: false, errors: preflight.errors };
|
||||
}
|
||||
|
||||
// Phase 2: Sync service directory
|
||||
if (hasServiceDirectory(serviceType)) {
|
||||
reportProgress("syncing_directory", "Syncing service directory...");
|
||||
|
||||
const sourcePath = getServiceBasePath(
|
||||
serviceType,
|
||||
appName,
|
||||
!!sourceServerId,
|
||||
);
|
||||
|
||||
try {
|
||||
await syncDirectory(
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
sourcePath,
|
||||
targetBasePath,
|
||||
(msg) => reportProgress("syncing_directory", msg),
|
||||
);
|
||||
reportProgress("syncing_directory", "Service directory synced");
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`Failed to sync service directory: ${msg}`);
|
||||
reportProgress("syncing_directory", `Error: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Sync Traefik config
|
||||
if (serviceType === "application" || serviceType === "compose") {
|
||||
reportProgress("syncing_traefik", "Syncing Traefik configuration...");
|
||||
try {
|
||||
await syncTraefikConfig(
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
appName,
|
||||
(msg) => reportProgress("syncing_traefik", msg),
|
||||
);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`Failed to sync Traefik config: ${msg}`);
|
||||
reportProgress("syncing_traefik", `Error: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: Sync all discovered Docker volumes
|
||||
reportProgress("syncing_mounts", "Syncing Docker volumes...");
|
||||
|
||||
for (const volumeName of discoveredVolumes) {
|
||||
reportProgress("syncing_mounts", `Syncing volume: ${volumeName}`);
|
||||
try {
|
||||
await syncDockerVolume(
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
volumeName,
|
||||
(msg) => reportProgress("syncing_mounts", msg),
|
||||
);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`Failed to sync volume ${volumeName}: ${msg}`);
|
||||
reportProgress("syncing_mounts", `Error: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: Sync user-defined mounts (bind mounts, etc.)
|
||||
for (const mountConfig of mountConfigs) {
|
||||
const mountLabel =
|
||||
mountConfig.volumeName || mountConfig.hostPath || mountConfig.mountPath;
|
||||
reportProgress("syncing_mounts", `Syncing: ${mountLabel}`);
|
||||
|
||||
try {
|
||||
await syncMount(
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
mountConfig,
|
||||
decisions,
|
||||
(msg) => reportProgress("syncing_mounts", msg),
|
||||
);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
errors.push(`Failed to sync mount ${mountLabel}: ${msg}`);
|
||||
reportProgress("syncing_mounts", `Error: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
reportProgress(
|
||||
"failed",
|
||||
`Transfer completed with errors: ${errors.join(", ")}`,
|
||||
);
|
||||
return { success: false, errors };
|
||||
}
|
||||
|
||||
reportProgress("completed", "Transfer completed successfully!");
|
||||
return { success: true, errors: [] };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
reportProgress("failed", `Transfer failed: ${message}`);
|
||||
return { success: false, errors: [message] };
|
||||
}
|
||||
};
|
||||
4
packages/server/src/utils/transfer/index.ts
Normal file
4
packages/server/src/utils/transfer/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./types";
|
||||
export * from "./scanner";
|
||||
export * from "./sync";
|
||||
export * from "./preflight";
|
||||
100
packages/server/src/utils/transfer/preflight.ts
Normal file
100
packages/server/src/utils/transfer/preflight.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import type { MountTransferConfig } from "./types";
|
||||
|
||||
const execOnServer = async (
|
||||
serverId: string | null,
|
||||
command: string,
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
if (serverId) {
|
||||
return execAsyncRemote(serverId, command);
|
||||
}
|
||||
return execAsync(command);
|
||||
};
|
||||
|
||||
export const ensureDirectoryExists = async (
|
||||
serverId: string | null,
|
||||
dirPath: string,
|
||||
): Promise<void> => {
|
||||
await execOnServer(serverId, `mkdir -p "${dirPath}"`);
|
||||
};
|
||||
|
||||
export const ensureVolumeExists = async (
|
||||
serverId: string | null,
|
||||
volumeName: string,
|
||||
): Promise<void> => {
|
||||
await execOnServer(
|
||||
serverId,
|
||||
`docker volume inspect ${volumeName} > /dev/null 2>&1 || docker volume create ${volumeName}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const checkDiskSpace = async (
|
||||
serverId: string | null,
|
||||
path: string,
|
||||
): Promise<number> => {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`df -B1 "${path}" | tail -1 | awk '{print $4}'`,
|
||||
);
|
||||
return Number.parseInt(stdout.trim(), 10);
|
||||
};
|
||||
|
||||
export const runPreflightChecks = async (
|
||||
targetServerId: string,
|
||||
targetBasePath: string,
|
||||
requiredBytes: number,
|
||||
mounts: MountTransferConfig[],
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<{ passed: boolean; errors: string[] }> => {
|
||||
const errors: string[] = [];
|
||||
|
||||
onLog?.("Checking disk space on target server...");
|
||||
try {
|
||||
const availableBytes = await checkDiskSpace(targetServerId, "/");
|
||||
if (availableBytes < requiredBytes * 1.2) {
|
||||
errors.push(
|
||||
`Insufficient disk space on target server. Required: ${formatBytes(requiredBytes)}, Available: ${formatBytes(availableBytes)}`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
errors.push("Failed to check disk space on target server");
|
||||
}
|
||||
|
||||
onLog?.("Ensuring target directories exist...");
|
||||
try {
|
||||
await ensureDirectoryExists(targetServerId, targetBasePath);
|
||||
} catch {
|
||||
errors.push(`Failed to create directory: ${targetBasePath}`);
|
||||
}
|
||||
|
||||
for (const mount of mounts) {
|
||||
if (mount.type === "volume" && mount.volumeName) {
|
||||
onLog?.(`Ensuring volume exists: ${mount.volumeName}`);
|
||||
try {
|
||||
await ensureVolumeExists(targetServerId, mount.volumeName);
|
||||
} catch {
|
||||
errors.push(`Failed to create volume: ${mount.volumeName}`);
|
||||
}
|
||||
} else if (mount.type === "bind" && mount.hostPath) {
|
||||
onLog?.(`Ensuring bind mount path exists: ${mount.hostPath}`);
|
||||
try {
|
||||
await ensureDirectoryExists(targetServerId, mount.hostPath);
|
||||
} catch {
|
||||
errors.push(`Failed to create directory: ${mount.hostPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
passed: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
|
||||
};
|
||||
300
packages/server/src/utils/transfer/scanner.ts
Normal file
300
packages/server/src/utils/transfer/scanner.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import type {
|
||||
ConflictStatus,
|
||||
FileConflict,
|
||||
FileInfo,
|
||||
MountTransferConfig,
|
||||
} from "./types";
|
||||
|
||||
const execOnServer = async (
|
||||
serverId: string | null,
|
||||
command: string,
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
if (serverId) {
|
||||
return execAsyncRemote(serverId, command);
|
||||
}
|
||||
return execAsync(command);
|
||||
};
|
||||
|
||||
export const scanDirectory = async (
|
||||
serverId: string | null,
|
||||
dirPath: string,
|
||||
): Promise<FileInfo[]> => {
|
||||
// Check if directory exists first
|
||||
try {
|
||||
const { stdout: exists } = await execOnServer(
|
||||
serverId,
|
||||
`test -d "${dirPath}" && echo "yes" || echo "no"`,
|
||||
);
|
||||
if (exists.trim() !== "yes") {
|
||||
return [];
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use find + stat -c (POSIX-compatible on Linux)
|
||||
// stat -c works on GNU coreutils (Debian, Ubuntu, etc.)
|
||||
const command = `find "${dirPath}" -type f -printf '%p|%s|%T@\\n' 2>/dev/null`;
|
||||
|
||||
try {
|
||||
const { stdout } = await execOnServer(serverId, command);
|
||||
if (!stdout.trim()) return [];
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parts = line.split("|");
|
||||
const filePath = parts[0] || "";
|
||||
const size = parts[1] || "0";
|
||||
const modifiedAt = parts[2] || "0";
|
||||
return {
|
||||
path: filePath.replace(dirPath, "").replace(/^\//, ""),
|
||||
size: Number.parseInt(size, 10),
|
||||
modifiedAt: Math.floor(Number.parseFloat(modifiedAt)),
|
||||
};
|
||||
})
|
||||
.filter((f) => f.path);
|
||||
} catch {
|
||||
// Fallback: try simpler ls-based approach
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`find "${dirPath}" -type f 2>/dev/null`,
|
||||
);
|
||||
if (!stdout.trim()) return [];
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((filePath) => ({
|
||||
path: filePath.replace(dirPath, "").replace(/^\//, ""),
|
||||
size: 0,
|
||||
modifiedAt: 0,
|
||||
}))
|
||||
.filter((f) => f.path);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const scanDockerVolume = async (
|
||||
serverId: string | null,
|
||||
volumeName: string,
|
||||
): Promise<FileInfo[]> => {
|
||||
// First check if volume exists
|
||||
try {
|
||||
const { stdout: exists } = await execOnServer(
|
||||
serverId,
|
||||
`docker volume inspect "${volumeName}" >/dev/null 2>&1 && echo "yes" || echo "no"`,
|
||||
);
|
||||
if (exists.trim() !== "yes") {
|
||||
return [];
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use busybox/alpine stat format (-c '%n|%s|%Y')
|
||||
const command = `docker run --rm -v "${volumeName}":/volume:ro alpine sh -c 'find /volume -type f -exec stat -c "%n|%s|%Y" {} + 2>/dev/null || find /volume -type f 2>/dev/null'`;
|
||||
|
||||
try {
|
||||
const { stdout } = await execOnServer(serverId, command);
|
||||
if (!stdout.trim()) return [];
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parts = line.split("|");
|
||||
if (parts.length >= 3) {
|
||||
return {
|
||||
path: (parts[0] || "").replace(/^\/volume\/?/, ""),
|
||||
size: Number.parseInt(parts[1] || "0", 10),
|
||||
modifiedAt: Number.parseInt(parts[2] || "0", 10),
|
||||
};
|
||||
}
|
||||
// Fallback: just file path
|
||||
return {
|
||||
path: line.replace(/^\/volume\/?/, ""),
|
||||
size: 0,
|
||||
modifiedAt: 0,
|
||||
};
|
||||
})
|
||||
.filter((f) => f.path);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getDirectorySize = async (
|
||||
serverId: string | null,
|
||||
dirPath: string,
|
||||
): Promise<number> => {
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`du -sb "${dirPath}" 2>/dev/null | awk '{print $1}'`,
|
||||
);
|
||||
return Number.parseInt(stdout.trim(), 10) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const getVolumeSize = async (
|
||||
serverId: string | null,
|
||||
volumeName: string,
|
||||
): Promise<number> => {
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`docker run --rm -v "${volumeName}":/volume:ro alpine du -sb /volume 2>/dev/null | awk '{print $1}'`,
|
||||
);
|
||||
return Number.parseInt(stdout.trim(), 10) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* List all Docker volumes belonging to a compose project.
|
||||
* Docker compose automatically labels volumes with com.docker.compose.project
|
||||
*/
|
||||
export const listComposeVolumes = async (
|
||||
serverId: string | null,
|
||||
projectName: string,
|
||||
): Promise<string[]> => {
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`docker volume ls --filter "label=com.docker.compose.project=${projectName}" --format "{{.Name}}" 2>/dev/null`,
|
||||
);
|
||||
if (!stdout.trim()) return [];
|
||||
return stdout.trim().split("\n").filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* List all Docker volumes that match a prefix pattern (appName_*).
|
||||
* Fallback for when compose labels are not available.
|
||||
*/
|
||||
export const listVolumesByPrefix = async (
|
||||
serverId: string | null,
|
||||
prefix: string,
|
||||
): Promise<string[]> => {
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`docker volume ls --format "{{.Name}}" 2>/dev/null | grep "^${prefix}" || true`,
|
||||
);
|
||||
if (!stdout.trim()) return [];
|
||||
return stdout.trim().split("\n").filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const computeFileHash = async (
|
||||
serverId: string | null,
|
||||
filePath: string,
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`md5sum "${filePath}" 2>/dev/null | awk '{print $1}'`,
|
||||
);
|
||||
return stdout.trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export const scanMount = async (
|
||||
serverId: string | null,
|
||||
mount: MountTransferConfig,
|
||||
): Promise<FileInfo[]> => {
|
||||
if (mount.type === "volume" && mount.volumeName) {
|
||||
return scanDockerVolume(serverId, mount.volumeName);
|
||||
}
|
||||
if (mount.type === "bind" && mount.hostPath) {
|
||||
return scanDirectory(serverId, mount.hostPath);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const compareFileLists = (
|
||||
sourceFiles: FileInfo[],
|
||||
targetFiles: FileInfo[],
|
||||
): FileConflict[] => {
|
||||
const targetMap = new Map<string, FileInfo>();
|
||||
for (const f of targetFiles) {
|
||||
targetMap.set(f.path, f);
|
||||
}
|
||||
|
||||
const conflicts: FileConflict[] = [];
|
||||
|
||||
for (const sourceFile of sourceFiles) {
|
||||
const targetFile = targetMap.get(sourceFile.path);
|
||||
|
||||
if (!targetFile) {
|
||||
conflicts.push({
|
||||
path: sourceFile.path,
|
||||
status: "missing_target",
|
||||
sourceFile,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
sourceFile.size === targetFile.size &&
|
||||
sourceFile.modifiedAt === targetFile.modifiedAt
|
||||
) {
|
||||
conflicts.push({
|
||||
path: sourceFile.path,
|
||||
status: "match",
|
||||
sourceFile,
|
||||
targetFile,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Different size or time = conflict
|
||||
let status: ConflictStatus;
|
||||
if (sourceFile.modifiedAt > targetFile.modifiedAt) {
|
||||
status = "newer_source";
|
||||
} else if (targetFile.modifiedAt > sourceFile.modifiedAt) {
|
||||
status = "newer_target";
|
||||
} else {
|
||||
status = "conflict";
|
||||
}
|
||||
|
||||
conflicts.push({
|
||||
path: sourceFile.path,
|
||||
status,
|
||||
sourceFile,
|
||||
targetFile,
|
||||
});
|
||||
}
|
||||
|
||||
// Files only on target
|
||||
for (const targetFile of targetFiles) {
|
||||
if (!sourceFiles.some((sf) => sf.path === targetFile.path)) {
|
||||
conflicts.push({
|
||||
path: targetFile.path,
|
||||
status: "newer_target",
|
||||
sourceFile: { path: targetFile.path, size: 0, modifiedAt: 0 },
|
||||
targetFile,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
};
|
||||
395
packages/server/src/utils/transfer/sync.ts
Normal file
395
packages/server/src/utils/transfer/sync.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { findServerById } from "../../services/server";
|
||||
import { Client } from "ssh2";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import type { ConflictDecision, MountTransferConfig } from "./types";
|
||||
|
||||
const execOnServer = async (
|
||||
serverId: string | null,
|
||||
command: string,
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
if (serverId) {
|
||||
return execAsyncRemote(serverId, command);
|
||||
}
|
||||
return execAsync(command);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a direct SSH connection to a server.
|
||||
* Used for streaming binary data (tar pipes) that can't go through execAsyncRemote.
|
||||
*/
|
||||
const getSSHConnection = async (
|
||||
serverId: string,
|
||||
): Promise<{ conn: Client }> => {
|
||||
const server = await findServerById(serverId);
|
||||
if (!server.sshKeyId) {
|
||||
throw new Error(`No SSH key configured for server ${server.name}`);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new Client();
|
||||
conn
|
||||
.on("ready", () => {
|
||||
resolve({ conn });
|
||||
})
|
||||
.on("error", (err) => {
|
||||
reject(
|
||||
new Error(
|
||||
`SSH connection failed to ${server.name} (${server.ipAddress}): ${err.message}`,
|
||||
),
|
||||
);
|
||||
})
|
||||
.connect({
|
||||
host: server.ipAddress,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
privateKey: server.sshKey?.privateKey,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Pipe a tar stream from source SSH connection to target SSH connection.
|
||||
*/
|
||||
const pipeSSH = (
|
||||
sourceConn: Client,
|
||||
targetConn: Client,
|
||||
sourceCmd: string,
|
||||
targetCmd: string,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
sourceConn.exec(sourceCmd, (err, sourceStream) => {
|
||||
if (err) return reject(new Error(`Source exec failed: ${err.message}`));
|
||||
|
||||
targetConn.exec(targetCmd, (err2, targetStream) => {
|
||||
if (err2)
|
||||
return reject(new Error(`Target exec failed: ${err2.message}`));
|
||||
|
||||
let totalBytes = 0;
|
||||
|
||||
sourceStream.on("data", (chunk: Buffer) => {
|
||||
totalBytes += chunk.length;
|
||||
targetStream.write(chunk);
|
||||
});
|
||||
|
||||
sourceStream.on("end", () => {
|
||||
targetStream.end();
|
||||
});
|
||||
|
||||
targetStream.on("close", () => {
|
||||
onLog?.(
|
||||
`Transferred ${(totalBytes / 1024 / 1024).toFixed(2)} MB`,
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
|
||||
sourceStream.on("error", (e: Error) =>
|
||||
reject(new Error(`Source stream error: ${e.message}`)),
|
||||
);
|
||||
targetStream.on("error", (e: Error) =>
|
||||
reject(new Error(`Target stream error: ${e.message}`)),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Stream data from local tar command into a remote SSH command.
|
||||
*/
|
||||
const pipeLocalToRemote = (
|
||||
targetConn: Client,
|
||||
localCmd: string,
|
||||
localArgs: string[],
|
||||
remoteCmd: string,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const localProcess = spawn(localCmd, localArgs, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
targetConn.exec(remoteCmd, (err, targetStream) => {
|
||||
if (err) {
|
||||
localProcess.kill();
|
||||
return reject(new Error(`Remote exec failed: ${err.message}`));
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
|
||||
localProcess.stdout.on("data", (chunk: Buffer) => {
|
||||
totalBytes += chunk.length;
|
||||
targetStream.write(chunk);
|
||||
});
|
||||
|
||||
localProcess.stdout.on("end", () => {
|
||||
targetStream.end();
|
||||
});
|
||||
|
||||
targetStream.on("close", () => {
|
||||
onLog?.(
|
||||
`Transferred ${(totalBytes / 1024 / 1024).toFixed(2)} MB`,
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
|
||||
localProcess.on("error", (e) => reject(e));
|
||||
targetStream.on("error", (e: Error) => reject(e));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Stream data from a remote SSH command into a local tar command.
|
||||
*/
|
||||
const pipeRemoteToLocal = (
|
||||
sourceConn: Client,
|
||||
remoteCmd: string,
|
||||
localCmd: string,
|
||||
localArgs: string[],
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const localProcess = spawn(localCmd, localArgs, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
sourceConn.exec(remoteCmd, (err, sourceStream) => {
|
||||
if (err) {
|
||||
localProcess.kill();
|
||||
return reject(new Error(`Remote exec failed: ${err.message}`));
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
|
||||
sourceStream.on("data", (chunk: Buffer) => {
|
||||
totalBytes += chunk.length;
|
||||
localProcess.stdin.write(chunk);
|
||||
});
|
||||
|
||||
sourceStream.on("end", () => {
|
||||
localProcess.stdin.end();
|
||||
});
|
||||
|
||||
localProcess.on("close", (code: number) => {
|
||||
onLog?.(
|
||||
`Transferred ${(totalBytes / 1024 / 1024).toFixed(2)} MB`,
|
||||
);
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Local process exited with code ${code}`));
|
||||
});
|
||||
|
||||
sourceStream.on("error", (e: Error) => reject(e));
|
||||
localProcess.on("error", (e) => reject(e));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const syncDirectory = async (
|
||||
sourceServerId: string | null,
|
||||
targetServerId: string,
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
onLog?.(`Syncing directory: ${sourcePath} → ${targetPath}`);
|
||||
|
||||
// Ensure target directory exists
|
||||
await execOnServer(targetServerId, `mkdir -p "${targetPath}"`);
|
||||
|
||||
if (sourceServerId && targetServerId) {
|
||||
// Remote → Remote: pipe tar directly between SSH connections
|
||||
onLog?.("Using direct SSH pipe for remote-to-remote transfer...");
|
||||
const [source, target] = await Promise.all([
|
||||
getSSHConnection(sourceServerId),
|
||||
getSSHConnection(targetServerId),
|
||||
]);
|
||||
try {
|
||||
await pipeSSH(
|
||||
source.conn,
|
||||
target.conn,
|
||||
`tar czf - -C "${sourcePath}" . 2>/dev/null`,
|
||||
`tar xzf - -C "${targetPath}"`,
|
||||
onLog,
|
||||
);
|
||||
} finally {
|
||||
source.conn.end();
|
||||
target.conn.end();
|
||||
}
|
||||
} else if (!sourceServerId && targetServerId) {
|
||||
// Local → Remote
|
||||
onLog?.("Transferring from local to remote...");
|
||||
const { conn } = await getSSHConnection(targetServerId);
|
||||
try {
|
||||
await pipeLocalToRemote(
|
||||
conn,
|
||||
"tar",
|
||||
["czf", "-", "-C", sourcePath, "."],
|
||||
`tar xzf - -C "${targetPath}"`,
|
||||
onLog,
|
||||
);
|
||||
} finally {
|
||||
conn.end();
|
||||
}
|
||||
} else if (sourceServerId && !targetServerId) {
|
||||
// Remote → Local
|
||||
onLog?.("Transferring from remote to local...");
|
||||
await execAsync(`mkdir -p "${targetPath}"`);
|
||||
const { conn } = await getSSHConnection(sourceServerId);
|
||||
try {
|
||||
await pipeRemoteToLocal(
|
||||
conn,
|
||||
`tar czf - -C "${sourcePath}" . 2>/dev/null`,
|
||||
"tar",
|
||||
["xzf", "-", "-C", targetPath],
|
||||
onLog,
|
||||
);
|
||||
} finally {
|
||||
conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
onLog?.(`Directory synced successfully: ${targetPath}`);
|
||||
};
|
||||
|
||||
export const syncDockerVolume = async (
|
||||
sourceServerId: string | null,
|
||||
targetServerId: string,
|
||||
volumeName: string,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
onLog?.(`Syncing Docker volume: ${volumeName}`);
|
||||
|
||||
// Ensure volume exists on target
|
||||
await execOnServer(
|
||||
targetServerId,
|
||||
`docker volume inspect "${volumeName}" > /dev/null 2>&1 || docker volume create "${volumeName}"`,
|
||||
);
|
||||
|
||||
const srcTarCmd = `docker run --rm -v "${volumeName}":/volume:ro alpine tar czf - -C /volume . 2>/dev/null`;
|
||||
const dstTarCmd = `docker run --rm -i -v "${volumeName}":/volume alpine tar xzf - -C /volume`;
|
||||
|
||||
if (sourceServerId && targetServerId) {
|
||||
// Remote → Remote
|
||||
onLog?.("Using direct SSH pipe for volume transfer...");
|
||||
const [source, target] = await Promise.all([
|
||||
getSSHConnection(sourceServerId),
|
||||
getSSHConnection(targetServerId),
|
||||
]);
|
||||
try {
|
||||
await pipeSSH(source.conn, target.conn, srcTarCmd, dstTarCmd, onLog);
|
||||
} finally {
|
||||
source.conn.end();
|
||||
target.conn.end();
|
||||
}
|
||||
} else if (!sourceServerId && targetServerId) {
|
||||
// Local → Remote
|
||||
onLog?.("Transferring volume from local to remote...");
|
||||
const { conn } = await getSSHConnection(targetServerId);
|
||||
try {
|
||||
await pipeLocalToRemote(
|
||||
conn,
|
||||
"docker",
|
||||
[
|
||||
"run", "--rm",
|
||||
"-v", `${volumeName}:/volume:ro`,
|
||||
"alpine", "tar", "czf", "-", "-C", "/volume", ".",
|
||||
],
|
||||
dstTarCmd,
|
||||
onLog,
|
||||
);
|
||||
} finally {
|
||||
conn.end();
|
||||
}
|
||||
} else if (sourceServerId && !targetServerId) {
|
||||
// Remote → Local
|
||||
onLog?.("Transferring volume from remote to local...");
|
||||
const { conn } = await getSSHConnection(sourceServerId);
|
||||
try {
|
||||
await pipeRemoteToLocal(
|
||||
conn,
|
||||
srcTarCmd,
|
||||
"docker",
|
||||
[
|
||||
"run", "--rm", "-i",
|
||||
"-v", `${volumeName}:/volume`,
|
||||
"alpine", "tar", "xzf", "-", "-C", "/volume",
|
||||
],
|
||||
onLog,
|
||||
);
|
||||
} finally {
|
||||
conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
onLog?.(`Volume synced successfully: ${volumeName}`);
|
||||
};
|
||||
|
||||
export const syncMount = async (
|
||||
sourceServerId: string | null,
|
||||
targetServerId: string,
|
||||
mount: MountTransferConfig,
|
||||
_decisions: Record<string, ConflictDecision>,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
if (mount.type === "volume" && mount.volumeName) {
|
||||
await syncDockerVolume(
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
mount.volumeName,
|
||||
onLog,
|
||||
);
|
||||
} else if (mount.type === "bind" && mount.hostPath) {
|
||||
await syncDirectory(
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
mount.hostPath,
|
||||
mount.hostPath,
|
||||
onLog,
|
||||
);
|
||||
} else if (mount.type === "file" && mount.content) {
|
||||
onLog?.("File mount will be recreated from database content during deploy");
|
||||
}
|
||||
};
|
||||
|
||||
export const syncTraefikConfig = async (
|
||||
sourceServerId: string | null,
|
||||
targetServerId: string,
|
||||
appName: string,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
onLog?.(`Syncing Traefik config for: ${appName}`);
|
||||
|
||||
const configPath = "/etc/dokploy/traefik/dynamic";
|
||||
const configFile = `${configPath}/${appName}.yml`;
|
||||
|
||||
let configContent: string;
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
sourceServerId,
|
||||
`cat "${configFile}" 2>/dev/null`,
|
||||
);
|
||||
configContent = stdout;
|
||||
} catch {
|
||||
onLog?.("No Traefik config found on source, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!configContent.trim()) {
|
||||
onLog?.("Empty Traefik config on source, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
await execOnServer(targetServerId, `mkdir -p "${configPath}"`);
|
||||
|
||||
const b64 = Buffer.from(configContent).toString("base64");
|
||||
await execOnServer(
|
||||
targetServerId,
|
||||
`echo "${b64}" | base64 -d > "${configFile}"`,
|
||||
);
|
||||
|
||||
onLog?.("Traefik config synced successfully");
|
||||
};
|
||||
91
packages/server/src/utils/transfer/types.ts
Normal file
91
packages/server/src/utils/transfer/types.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
export type ServiceType =
|
||||
| "application"
|
||||
| "compose"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "redis";
|
||||
|
||||
export interface FileInfo {
|
||||
path: string;
|
||||
size: number;
|
||||
modifiedAt: number;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
export type ConflictStatus =
|
||||
| "missing_target"
|
||||
| "newer_source"
|
||||
| "newer_target"
|
||||
| "conflict"
|
||||
| "match";
|
||||
|
||||
export interface FileConflict {
|
||||
path: string;
|
||||
status: ConflictStatus;
|
||||
sourceFile: FileInfo;
|
||||
targetFile?: FileInfo;
|
||||
}
|
||||
|
||||
export interface MountTransferConfig {
|
||||
mountId: string;
|
||||
type: "bind" | "volume" | "file";
|
||||
hostPath?: string | null;
|
||||
volumeName?: string | null;
|
||||
mountPath: string;
|
||||
content?: string | null;
|
||||
filePath?: string | null;
|
||||
}
|
||||
|
||||
export interface TransferScanResult {
|
||||
serviceDirectory: {
|
||||
files: FileConflict[];
|
||||
totalSize: number;
|
||||
};
|
||||
traefikConfig: {
|
||||
exists: boolean;
|
||||
hasConflict: boolean;
|
||||
};
|
||||
mounts: Array<{
|
||||
mount: MountTransferConfig;
|
||||
files: FileConflict[];
|
||||
totalSize: number;
|
||||
}>;
|
||||
totalTransferSize: number;
|
||||
totalFiles: number;
|
||||
conflicts: FileConflict[];
|
||||
}
|
||||
|
||||
export type ConflictDecision = "skip" | "overwrite";
|
||||
|
||||
export interface TransferProgress {
|
||||
phase:
|
||||
| "preparing"
|
||||
| "syncing_directory"
|
||||
| "syncing_traefik"
|
||||
| "syncing_mounts"
|
||||
| "updating_database"
|
||||
| "completed"
|
||||
| "failed";
|
||||
currentFile?: string;
|
||||
processedFiles: number;
|
||||
totalFiles: number;
|
||||
transferredBytes: number;
|
||||
totalBytes: number;
|
||||
percentage: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface TransferOptions {
|
||||
serviceId: string;
|
||||
serviceType: ServiceType;
|
||||
appName: string;
|
||||
sourceServerId: string | null;
|
||||
targetServerId: string;
|
||||
}
|
||||
|
||||
export interface TransferResult {
|
||||
success: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
import { renderAsync } from "@react-email/components";
|
||||
import VerifyEmailTemplate from "../emails/emails/verify-email";
|
||||
import { sendEmailNotification } from "../utils/notifications/utils";
|
||||
|
||||
export const sendEmail = async ({
|
||||
email,
|
||||
subject,
|
||||
@@ -29,25 +26,3 @@ export const sendEmail = async ({
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const sendVerificationEmail = async ({
|
||||
userName,
|
||||
email,
|
||||
verificationUrl,
|
||||
}: {
|
||||
userName: string;
|
||||
email: string;
|
||||
verificationUrl: string;
|
||||
}) => {
|
||||
const html = await renderAsync(
|
||||
VerifyEmailTemplate({
|
||||
userName: userName || "User",
|
||||
verificationUrl,
|
||||
}),
|
||||
);
|
||||
await sendEmail({
|
||||
email,
|
||||
subject: "Verify your email",
|
||||
text: html,
|
||||
});
|
||||
};
|
||||
|
||||
767
pnpm-lock.yaml
generated
767
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user