Compare commits

..

4 Commits

Author SHA1 Message Date
autofix-ci[bot]
3e2eb7213d [autofix.ci] apply automated fixes 2026-04-05 19:41:38 +00:00
Mauricio Siu
dcb95374da fix: also collect added/removed paths and filter nullish values
commit.modified can be undefined causing micromatch to throw
"Expected input to be a string". Also includes added and removed
paths from commits so watch paths can match against all changed files.
2026-04-05 13:41:17 -06:00
autofix-ci[bot]
36e131cf12 [autofix.ci] apply automated fixes 2026-04-05 19:38:19 +00:00
Mauricio Siu
17b4c0fc58 fix: webhook crash when commits array is missing and watch paths enabled
When a GitHub webhook fires with undefined commits and watch paths are
configured, flatMap on undefined crashes the handler. Default to empty
array so shouldDeploy can handle it gracefully.

Closes #4081
2026-04-05 13:37:44 -06:00
85 changed files with 499 additions and 14402 deletions

View File

@@ -424,26 +424,6 @@ test("Custom entrypoint with internalPath adds addprefix middleware", async () =
expect(router.entryPoints).toEqual(["custom"]);
});
test("stripPath and internalPath together: stripprefix must come before addprefix", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
path: "/public",
stripPath: true,
internalPath: "/app/v2",
},
"web",
);
const stripIndex = router.middlewares?.indexOf("stripprefix--1") ?? -1;
const addIndex = router.middlewares?.indexOf("addprefix--1") ?? -1;
expect(stripIndex).toBeGreaterThanOrEqual(0);
expect(addIndex).toBeGreaterThanOrEqual(0);
expect(stripIndex).toBeLessThan(addIndex);
});
test("Custom entrypoint with https and custom cert resolver", async () => {
const router = await createRouterConfig(
baseApp,

View File

@@ -1,7 +1,6 @@
import copy from "copy-to-clipboard";
import { Check, Copy, Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { AnalyzeLogs } from "@/components/dashboard/docker/logs/analyze-logs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -166,7 +165,6 @@ export const ShowDeployment = ({
<Copy className="h-3.5 w-3.5" />
)}
</Button>
<AnalyzeLogs logs={filteredLogs} context="build" />
{serverId && (
<div className="flex items-center space-x-2">

View File

@@ -56,17 +56,17 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const [isEnvVisible, setIsEnvVisible] = useState(true);
const mutationMap = {
compose: () => api.compose.saveEnvironment.useMutation(),
libsql: () => api.libsql.saveEnvironment.useMutation(),
mariadb: () => api.mariadb.saveEnvironment.useMutation(),
mongo: () => api.mongo.saveEnvironment.useMutation(),
mysql: () => api.mysql.saveEnvironment.useMutation(),
postgres: () => api.postgres.saveEnvironment.useMutation(),
redis: () => api.redis.saveEnvironment.useMutation(),
compose: () => api.compose.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
};
const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]()
: api.mongo.saveEnvironment.useMutation();
: api.mongo.update.useMutation();
const form = useForm<EnvironmentSchema>({
defaultValues: {

View File

@@ -55,7 +55,7 @@ interface Props {
export const SaveGitProvider = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery({ applicationId });
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
const { data: sshKeys } = api.sshKey.all.useQuery();
const router = useRouter();
const { mutateAsync, isPending } =

View File

@@ -1,290 +0,0 @@
import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react";
import dynamic from "next/dynamic";
import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
import { ShowContainerConfig } from "@/components/dashboard/docker/config/show-container-config";
import { ShowContainerMounts } from "@/components/dashboard/docker/mounts/show-container-mounts";
import { ShowContainerNetworks } from "@/components/dashboard/docker/networks/show-container-networks";
import { DockerTerminalModal } from "@/components/dashboard/docker/terminal/docker-terminal-modal";
const DockerLogsId = dynamic(
() =>
import("@/components/dashboard/docker/logs/docker-logs-id").then(
(e) => e.DockerLogsId,
),
{
ssr: false,
},
);
interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
}
export const ShowComposeContainers = ({
appName,
appType,
serverId,
}: Props) => {
const { data, isPending, refetch } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
appType,
serverId,
},
{
enabled: !!appName,
},
);
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-xl">Containers</CardTitle>
<CardDescription>
Inspect each container in this compose and run basic lifecycle
actions.
</CardDescription>
</div>
<Button
variant="outline"
size="icon"
onClick={() => refetch()}
disabled={isPending}
>
<RefreshCw className={`h-4 w-4 ${isPending ? "animate-spin" : ""}`} />
</Button>
</CardHeader>
<CardContent>
{isPending ? (
<div className="flex items-center justify-center h-[20vh]">
<Loader2 className="animate-spin h-6 w-6 text-muted-foreground" />
</div>
) : !data || data.length === 0 ? (
<div className="flex items-center justify-center h-[20vh]">
<span className="text-muted-foreground">
No containers found. Deploy the compose to see containers here.
</span>
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>State</TableHead>
<TableHead>Status</TableHead>
<TableHead>Container ID</TableHead>
<TableHead className="text-right" />
</TableRow>
</TableHeader>
<TableBody>
{data.map((container) => (
<ContainerRow
key={container.containerId}
container={container}
serverId={serverId}
onActionComplete={() => refetch()}
/>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
};
interface ContainerRowProps {
container: {
containerId: string;
name: string;
state: string;
status: string;
};
serverId?: string;
onActionComplete: () => void;
}
const ContainerRow = ({
container,
serverId,
onActionComplete,
}: ContainerRowProps) => {
const [logsOpen, setLogsOpen] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const restartMutation = api.docker.restartContainer.useMutation();
const startMutation = api.docker.startContainer.useMutation();
const stopMutation = api.docker.stopContainer.useMutation();
const killMutation = api.docker.killContainer.useMutation();
const handleAction = async (
action: string,
mutationFn: typeof restartMutation,
) => {
setActionLoading(action);
try {
await mutationFn.mutateAsync({
containerId: container.containerId,
serverId,
});
toast.success(`Container ${action} successfully`);
onActionComplete();
} catch (error) {
toast.error(
`Failed to ${action} container: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setActionLoading(null);
}
};
return (
<TableRow>
<TableCell className="font-medium">{container.name}</TableCell>
<TableCell>
<Badge
variant={
container.state === "running"
? "default"
: container.state === "exited"
? "secondary"
: "destructive"
}
>
{container.state}
</Badge>
</TableCell>
<TableCell>{container.status}</TableCell>
<TableCell className="font-mono text-sm text-muted-foreground">
{container.containerId}
</TableCell>
<TableCell className="text-right">
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
{actionLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DialogTrigger asChild>
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Logs
</DropdownMenuItem>
</DialogTrigger>
<ShowContainerConfig
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerMounts
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerNetworks
containerId={container.containerId}
serverId={serverId || ""}
/>
<DockerTerminalModal
containerId={container.containerId}
serverId={serverId || ""}
>
Terminal
</DockerTerminalModal>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("restart", restartMutation)}
>
Restart
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("start", startMutation)}
>
Start
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("stop", stopMutation)}
>
Stop
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer text-red-500 focus:text-red-600"
disabled={actionLoading !== null}
onClick={() => handleAction("kill", killMutation)}
>
Kill
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent className="sm:max-w-7xl">
<DialogHeader>
<DialogTitle>View Logs</DialogTitle>
<DialogDescription>Logs for {container.name}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2.5">
<DockerLogsId
containerId={container.containerId}
serverId={serverId}
runType="native"
/>
</div>
</DialogContent>
</Dialog>
</TableCell>
</TableRow>
);
};

View File

@@ -55,7 +55,7 @@ interface Props {
export const SaveGitProviderCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery({ composeId });
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
const { data: sshKeys } = api.sshKey.all.useQuery();
const router = useRouter();
const { mutateAsync, isPending } = api.compose.update.useMutation();

View File

@@ -1,189 +0,0 @@
"use client";
import { Bot, Loader2, RotateCcw, Settings, X } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import type { LogLine } from "./utils";
interface Props {
logs: LogLine[];
context: "build" | "runtime";
}
const MAX_LOG_LINES = 200;
export function AnalyzeLogs({ logs, context }: Props) {
const [open, setOpen] = useState(false);
const [aiId, setAiId] = useState<string>("");
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
enabled: open,
});
const { mutate, isPending, data, reset } = api.ai.analyzeLogs.useMutation({
onError: (error) => {
toast.error("Analysis failed", {
description: error.message,
});
},
});
const handleAnalyze = () => {
if (!aiId || logs.length === 0) return;
const logsText = logs
.slice(-MAX_LOG_LINES)
.map((l) => l.message)
.join("\n");
mutate({ aiId, logs: logsText, context });
};
return (
<Popover
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen) {
reset();
setAiId("");
}
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9"
disabled={logs.length === 0}
title="Analyze logs with AI"
>
<Bot className="mr-2 h-4 w-4" />
AI
</Button>
</PopoverTrigger>
<PopoverContent className="w-[550px] p-0" align="end">
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<Bot className="h-4 w-4" />
<span className="text-sm font-medium">Log Analysis</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setOpen(false)}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="p-4 space-y-3">
{!data?.analysis ? (
providers && providers.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-2 text-center">
<p className="text-sm text-muted-foreground">
No AI providers configured. Set up a provider to start
analyzing logs.
</p>
<Button size="sm" variant="outline" asChild>
<Link href="/dashboard/settings/ai">
<Settings className="mr-2 h-3.5 w-3.5" />
Configure AI Provider
</Link>
</Button>
</div>
) : (
<>
<Select value={aiId} onValueChange={setAiId}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select AI provider..." />
</SelectTrigger>
<SelectContent>
{providers?.map((p) => (
<SelectItem key={p.aiId} value={p.aiId}>
{p.name} ({p.model})
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
className="w-full"
disabled={!aiId || isPending || logs.length === 0}
onClick={handleAnalyze}
>
{isPending ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Analyzing...
</>
) : (
<>
<Bot className="mr-2 h-3.5 w-3.5" />
Analyze{" "}
{logs.length > MAX_LOG_LINES
? `last ${MAX_LOG_LINES}`
: logs.length}{" "}
lines
</>
)}
</Button>
</>
)
) : (
<>
<div className="max-h-[400px] overflow-y-auto">
<div className="prose prose-sm dark:prose-invert max-w-none text-sm break-words">
<ReactMarkdown>{data.analysis}</ReactMarkdown>
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={() => {
reset();
handleAnalyze();
}}
disabled={isPending}
>
{isPending ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="mr-2 h-3.5 w-3.5" />
)}
Re-analyze
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
reset();
setAiId("");
}}
title="Change provider"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -12,7 +12,6 @@ import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { AnalyzeLogs } from "./analyze-logs";
import { LineCountFilter } from "./line-count-filter";
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter";
import { StatusLogsFilter } from "./status-logs-filter";
@@ -378,7 +377,6 @@ export const DockerLogsId: React.FC<Props> = ({
<DownloadIcon className="mr-2 h-4 w-4" />
Download logs
</Button>
<AnalyzeLogs logs={filteredLogs} context="runtime" />
</div>
</div>
{isPaused && (

View File

@@ -1,112 +0,0 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { api } from "@/utils/api";
interface Props {
containerId: string;
serverId?: string;
}
interface Mount {
Type: string;
Source: string;
Destination: string;
Mode: string;
RW: boolean;
Propagation: string;
Name?: string;
Driver?: string;
}
export const ShowContainerMounts = ({ containerId, serverId }: Props) => {
const { data } = api.docker.getConfig.useQuery(
{
containerId,
serverId,
},
{
enabled: !!containerId,
},
);
const mounts: Mount[] = data?.Mounts ?? [];
return (
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Mounts
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Container Mounts</DialogTitle>
<DialogDescription>
Volume and bind mounts for this container
</DialogDescription>
</DialogHeader>
<div className="overflow-auto max-h-[70vh]">
{mounts.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
No mounts found for this container.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Source</TableHead>
<TableHead>Destination</TableHead>
<TableHead>Mode</TableHead>
<TableHead>Read/Write</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mounts.map((mount, index) => (
<TableRow key={index}>
<TableCell>
<Badge variant="outline">{mount.Type}</Badge>
</TableCell>
<TableCell className="font-mono text-xs max-w-[250px] truncate">
{mount.Name || mount.Source}
</TableCell>
<TableCell className="font-mono text-xs max-w-[250px] truncate">
{mount.Destination}
</TableCell>
<TableCell className="text-xs">
{mount.Mode || "-"}
</TableCell>
<TableCell>
<Badge variant={mount.RW ? "default" : "secondary"}>
{mount.RW ? "RW" : "RO"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,119 +0,0 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { api } from "@/utils/api";
interface Props {
containerId: string;
serverId?: string;
}
interface Network {
IPAMConfig: unknown;
Links: unknown;
Aliases: string[] | null;
MacAddress: string;
NetworkID: string;
EndpointID: string;
Gateway: string;
IPAddress: string;
IPPrefixLen: number;
IPv6Gateway: string;
GlobalIPv6Address: string;
GlobalIPv6PrefixLen: number;
DriverOpts: unknown;
}
export const ShowContainerNetworks = ({ containerId, serverId }: Props) => {
const { data } = api.docker.getConfig.useQuery(
{
containerId,
serverId,
},
{
enabled: !!containerId,
},
);
const networks: Record<string, Network> =
data?.NetworkSettings?.Networks ?? {};
const entries = Object.entries(networks);
return (
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Networks
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Container Networks</DialogTitle>
<DialogDescription>
Networks attached to this container
</DialogDescription>
</DialogHeader>
<div className="overflow-auto max-h-[70vh]">
{entries.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
No networks found for this container.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Network</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Gateway</TableHead>
<TableHead>MAC Address</TableHead>
<TableHead>Aliases</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map(([name, network]) => (
<TableRow key={name}>
<TableCell>
<Badge variant="outline">{name}</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{network.IPAddress
? `${network.IPAddress}/${network.IPPrefixLen}`
: "-"}
</TableCell>
<TableCell className="font-mono text-xs">
{network.Gateway || "-"}
</TableCell>
<TableCell className="font-mono text-xs">
{network.MacAddress || "-"}
</TableCell>
<TableCell className="text-xs">
{network.Aliases?.join(", ") || "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -10,8 +10,6 @@ import {
} from "@/components/ui/dropdown-menu";
import { ShowContainerConfig } from "../config/show-container-config";
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
import { ShowContainerMounts } from "../mounts/show-container-mounts";
import { ShowContainerNetworks } from "../networks/show-container-networks";
import { RemoveContainerDialog } from "../remove/remove-container";
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
import { UploadFileModal } from "../upload/upload-file-modal";
@@ -125,14 +123,6 @@ export const columns: ColumnDef<Container>[] = [
containerId={container.containerId}
serverId={container.serverId || ""}
/>
<ShowContainerMounts
containerId={container.containerId}
serverId={container.serverId || ""}
/>
<ShowContainerNetworks
containerId={container.containerId}
serverId={container.serverId || ""}
/>
<DockerTerminalModal
containerId={container.containerId}
serverId={container.serverId || ""}

View File

@@ -82,8 +82,7 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
const buildConnectionUrl = () => {
const port = form.watch("externalPort") || data?.externalPort;
const params = `authSource=admin${data?.replicaSets ? "" : "&directConnection=true"}`;
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/?${params}`;
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
};
setConnectionUrl(buildConnectionUrl());

View File

@@ -62,7 +62,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<Label>Internal Connection URL </Label>
<ToggleVisibilityInput
disabled
value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017/?authSource=admin${data?.replicaSets ? "" : "&directConnection=true"}`}
value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017`}
/>
</div>
</div>

View File

@@ -220,11 +220,11 @@ export const ContainerFreeMonitoring = ({
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
Used: {String(currentData.cpu.value ?? "0%")}
Used: {currentData.cpu.value}
</span>
<Progress
value={Number.parseInt(
String(currentData.cpu.value ?? "0%").replace("%", ""),
currentData.cpu.value.replace("%", ""),
10,
)}
className="w-[100%]"

View File

@@ -298,19 +298,7 @@ export const TemplateGenerator = ({ environmentId }: Props) => {
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 w-full justify-end">
<Button
onClick={() => {
if (
stepper.current.id === "variant" &&
templateInfo.details
) {
setTemplateInfo((prev) => ({
...prev,
details: null,
}));
return;
}
stepper.prev();
}}
onClick={stepper.prev}
disabled={stepper.isFirst}
variant="secondary"
>

View File

@@ -2,7 +2,6 @@ import { loadStripe } from "@stripe/stripe-js";
import clsx from "clsx";
import {
AlertTriangle,
Bell,
CheckIcon,
CreditCard,
FileText,
@@ -26,17 +25,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { NumberInput } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
@@ -101,8 +90,6 @@ export const ShowBilling = () => {
api.stripe.createCustomerPortalSession.useMutation();
const { mutateAsync: upgradeSubscription, isPending: isUpgrading } =
api.stripe.upgradeSubscription.useMutation();
const { mutateAsync: updateInvoiceNotifications } =
api.stripe.updateInvoiceNotifications.useMutation();
const utils = api.useUtils();
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
@@ -164,66 +151,14 @@ export const ShowBilling = () => {
<div className="w-full">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader className="flex flex-row items-start justify-between">
<div>
<CardTitle className="text-xl flex flex-row gap-2">
<CreditCard className="size-6 text-muted-foreground self-center" />
Billing
</CardTitle>
<CardDescription>
Manage your subscription and invoices
</CardDescription>
</div>
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<Bell className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Notification Settings</DialogTitle>
<DialogDescription>
Configure your billing email notifications.
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="invoice-notifications">
Invoice Notifications
</Label>
<p className="text-sm text-muted-foreground">
Receive email notifications for payments and failed
charges.
</p>
</div>
<Switch
id="invoice-notifications"
checked={admin?.user.sendInvoiceNotifications ?? false}
onCheckedChange={async (checked) => {
await updateInvoiceNotifications({
enabled: checked,
})
.then(() => {
utils.user.get.invalidate();
toast.success(
checked
? "Invoice notifications enabled"
: "Invoice notifications disabled",
);
})
.catch(() => {
toast.error(
"Failed to update invoice notifications",
);
});
}}
/>
</div>
</DialogContent>
</Dialog>
)}
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<CreditCard className="size-6 text-muted-foreground self-center" />
Billing
</CardTitle>
<CardDescription>
Manage your subscription and invoices
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 py-4 border-t">
<nav className="flex space-x-2 border-b">

View File

@@ -1,13 +1,6 @@
"use client";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import {
Check,
ChevronDown,
Loader2,
PenBoxIcon,
Plug,
PlusIcon,
} from "lucide-react";
import { Check, ChevronDown, PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -44,34 +37,10 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
const AI_PROVIDERS = [
{ name: "OpenAI", apiUrl: "https://api.openai.com/v1" },
{ name: "Anthropic", apiUrl: "https://api.anthropic.com/v1" },
{
name: "Google Gemini",
apiUrl: "https://generativelanguage.googleapis.com/v1beta",
},
{ name: "Mistral", apiUrl: "https://api.mistral.ai/v1" },
{ name: "Cohere", apiUrl: "https://api.cohere.ai/v2" },
{ name: "Perplexity", apiUrl: "https://api.perplexity.ai" },
{ name: "DeepInfra", apiUrl: "https://api.deepinfra.com/v1/openai" },
{ name: "Ollama", apiUrl: "http://localhost:11434" },
{ name: "OpenRouter", apiUrl: "https://openrouter.ai/api/v1" },
{ name: "Z.AI", apiUrl: "https://api.z.ai/api/paas/v4" },
{ name: "MiniMax", apiUrl: "https://api.minimax.io/v1" },
] as const;
const Schema = z.object({
name: z.string().min(1, { message: "Name is required" }),
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
@@ -134,7 +103,7 @@ export const HandleAi = ({ aiId }: Props) => {
const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
const {
data: models,
isFetching: isLoadingServerModels,
isPending: isLoadingServerModels,
error: modelsError,
} = api.ai.getModels.useQuery(
{
@@ -203,34 +172,6 @@ export const HandleAi = ({ aiId }: Props) => {
<AlertBlock type="error">{modelsError.message}</AlertBlock>
)}
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
<div className="space-y-1">
<FormLabel>Provider</FormLabel>
<Select
onValueChange={(value) => {
const provider = AI_PROVIDERS.find((p) => p.apiUrl === value);
if (provider) {
form.setValue("name", provider.name);
form.setValue("apiUrl", provider.apiUrl);
form.setValue("model", "");
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a provider preset..." />
</SelectTrigger>
<SelectContent>
{AI_PROVIDERS.map((provider) => (
<SelectItem key={provider.apiUrl} value={provider.apiUrl}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[0.8rem] text-muted-foreground">
Quick-fill provider name and URL, or configure manually below
</p>
</div>
<FormField
control={form.control}
name="name"
@@ -312,129 +253,101 @@ export const HandleAi = ({ aiId }: Props) => {
</span>
)}
<FormField
control={form.control}
name="model"
render={({ field }) => {
const hasModels =
!isLoadingServerModels && models && models.length > 0;
const selectedModel = models?.find((m) => m.id === field.value);
const filteredModels = (models ?? []).filter((model) =>
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
);
{!isLoadingServerModels && !models?.length && (
<span className="text-sm text-muted-foreground">
No models available
</span>
)}
const displayModels =
field.value &&
!filteredModels.find((m) => m.id === field.value) &&
selectedModel
? [selectedModel, ...filteredModels]
: filteredModels;
{!isLoadingServerModels && models && models.length > 0 && (
<FormField
control={form.control}
name="model"
render={({ field }) => {
const selectedModel = models.find(
(m) => m.id === field.value,
);
const filteredModels = models.filter((model) =>
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
);
return (
<FormItem>
<FormLabel>Model</FormLabel>
<div className="flex gap-2">
<div className="flex-1">
{hasModels ? (
<Popover
open={modelPopoverOpen}
onOpenChange={setModelPopoverOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
{field.value
? (selectedModel?.id ?? field.value)
: "Select a model"}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[400px] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search or type a custom model..."
value={modelSearch}
onValueChange={setModelSearch}
/>
<CommandList>
<CommandEmpty>
{modelSearch ? (
<button
type="button"
className="w-full cursor-pointer px-2 py-1.5 text-left text-sm hover:bg-accent"
onClick={() => {
field.onChange(modelSearch);
setModelPopoverOpen(false);
setModelSearch("");
}}
>
Use custom model: "{modelSearch}"
</button>
) : (
"No models found."
)}
</CommandEmpty>
{displayModels.map((model) => {
const isSelected = field.value === model.id;
return (
<CommandItem
key={model.id}
value={model.id}
onSelect={() => {
field.onChange(model.id);
setModelPopoverOpen(false);
setModelSearch("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
isSelected
? "opacity-100"
: "opacity-0",
)}
/>
{model.id}
</CommandItem>
);
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
// Ensure selected model is always in the filtered list
const displayModels =
field.value &&
!filteredModels.find((m) => m.id === field.value) &&
selectedModel
? [selectedModel, ...filteredModels]
: filteredModels;
return (
<FormItem>
<FormLabel>Model</FormLabel>
<Popover
open={modelPopoverOpen}
onOpenChange={setModelPopoverOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Input
placeholder={
isLoadingServerModels
? "Loading models..."
: "Enter model name (e.g. gpt-4o)"
}
disabled={isLoadingServerModels}
{...field}
/>
<Button
variant="outline"
className={cn(
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
{field.value
? (selectedModel?.id ?? field.value)
: "Select a model"}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
)}
</div>
</div>
<FormDescription>
Select a model from the list or type a custom model name
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput
placeholder="Search models..."
value={modelSearch}
onValueChange={setModelSearch}
/>
<CommandList>
<CommandEmpty>No models found.</CommandEmpty>
{displayModels.map((model) => {
const isSelected = field.value === model.id;
return (
<CommandItem
key={model.id}
value={model.id}
onSelect={() => {
field.onChange(model.id);
setModelPopoverOpen(false);
setModelSearch("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
isSelected
? "opacity-100"
: "opacity-0",
)}
/>
{model.id}
</CommandItem>
);
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
Select an AI model to use
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
)}
<FormField
control={form.control}
@@ -459,12 +372,7 @@ export const HandleAi = ({ aiId }: Props) => {
)}
/>
<div className="flex justify-end gap-2 pt-4">
<TestConnectionButton
apiUrl={apiUrl}
apiKey={apiKey}
model={form.watch("model")}
/>
<div className="flex justify-end gap-2 pt-4">
<Button type="submit" isLoading={isPending}>
{aiId ? "Update" : "Create"}
</Button>
@@ -475,42 +383,3 @@ export const HandleAi = ({ aiId }: Props) => {
</Dialog>
);
};
function TestConnectionButton({
apiUrl,
apiKey,
model,
}: {
apiUrl: string;
apiKey: string;
model: string;
}) {
const { mutate, isPending } = api.ai.testConnection.useMutation({
onSuccess: () => {
toast.success("Connection successful");
},
onError: (error) => {
toast.error("Connection failed", {
description: error.message,
});
},
});
const isDisabled = !apiUrl || !model;
return (
<Button
type="button"
variant="outline"
disabled={isDisabled || isPending}
onClick={() => mutate({ apiUrl, apiKey, model })}
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plug className="mr-2 h-4 w-4" />
)}
Test Connection
</Button>
);
}

View File

@@ -1,14 +1,7 @@
import { toast } from "sonner";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { HelpCircle } from "lucide-react";
import { toast } from "sonner";
interface Props {
serverId?: string;
@@ -59,36 +52,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
return (
<div className="flex items-center gap-4">
<Switch checked={!!enabled} onCheckedChange={handleToggle} />
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Label className="text-primary flex items-center gap-1.5 cursor-pointer">
Daily Docker Cleanup
<HelpCircle className="size-4 text-muted-foreground" />
</Label>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-sm">
<p>
Runs a full Docker cleanup daily, pruning stopped containers,
unused images, volumes, build cache, and system resources. This
may remove images built for Compose services that run on-demand
(backup runners, cron jobs, one-off tasks).
</p>
<p className="mt-1">
For custom cleanup strategies, use{" "}
<a
href="https://docs.dokploy.com/docs/core/schedule-jobs#example-1-automatic-docker-cleanup"
target="_blank"
rel="noopener noreferrer"
className="underline text-primary"
>
Schedule Jobs
</a>{" "}
on your web server or remote servers.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Label className="text-primary">Daily Docker Cleanup</Label>
</div>
);
};

View File

@@ -55,8 +55,7 @@ export const WelcomeSubscription = () => {
const [showConfetti, setShowConfetti] = useState(false);
const stepper = useStepper();
const [isOpen, setIsOpen] = useState(true);
const router = useRouter();
const { push } = router;
const { push } = useRouter();
useEffect(() => {
const confettiShown = localStorage.getItem("hasShownConfetti");
@@ -67,22 +66,7 @@ export const WelcomeSubscription = () => {
}, [showConfetti]);
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
const { success, ...rest } = router.query;
router.replace(
{ pathname: router.pathname, query: rest },
undefined,
{
shallow: true,
},
);
}
}}
>
<Dialog open={isOpen}>
<DialogContent className="sm:max-w-7xl min-h-[75vh]">
{showConfetti ?? "Flaso"}
<div className="flex justify-center items-center w-full">

View File

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

View File

@@ -1,596 +0,0 @@
import {
AlertTriangle,
ArrowRightLeft,
Loader2,
Server,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DrawerLogs } from "@/components/shared/drawer-logs";
import type { LogLine } from "@/components/dashboard/docker/logs/utils";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
type ServiceType =
| "application"
| "compose"
| "postgres"
| "mysql"
| "mariadb"
| "mongo"
| "redis";
interface TransferServiceProps {
serviceId: string;
serviceType: ServiceType;
currentServerId: string | null;
}
interface ScanResult {
serviceDirectory: {
files: Array<{
path: string;
status: string;
sourceFile: { path: string; size: number; modifiedAt: number };
targetFile?: { path: string; size: number; modifiedAt: number };
}>;
totalSize: number;
};
traefikConfig: {
exists: boolean;
hasConflict: boolean;
};
mounts: Array<{
mount: {
mountId: string;
type: string;
volumeName?: string | null;
hostPath?: string | null;
mountPath: string;
};
files: Array<{
path: string;
status: string;
}>;
totalSize: number;
}>;
totalTransferSize: number;
totalFiles: number;
conflicts: Array<{
path: string;
status: string;
}>;
}
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
};
const useScanMutation = (serviceType: ServiceType) => {
const mutations = {
application: api.application.transferScan.useMutation(),
compose: api.compose.transferScan.useMutation(),
postgres: api.postgres.transferScan.useMutation(),
mysql: api.mysql.transferScan.useMutation(),
mariadb: api.mariadb.transferScan.useMutation(),
mongo: api.mongo.transferScan.useMutation(),
redis: api.redis.transferScan.useMutation(),
};
return mutations[serviceType];
};
const getServiceIdKey = (serviceType: ServiceType): string => {
const map: Record<ServiceType, string> = {
application: "applicationId",
compose: "composeId",
postgres: "postgresId",
mysql: "mysqlId",
mariadb: "mariadbId",
mongo: "mongoId",
redis: "redisId",
};
return map[serviceType];
};
export const TransferService = ({
serviceId,
serviceType,
currentServerId,
}: TransferServiceProps) => {
const [targetServerId, setTargetServerId] = useState<string>("");
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [step, setStep] = useState<"select" | "scan" | "confirm">("select");
const [showConfirm, setShowConfirm] = useState(false);
// Drawer logs state
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isTransferring, setIsTransferring] = useState(false);
const { data: servers } = api.server.all.useQuery();
const utils = api.useUtils();
const scan = useScanMutation(serviceType);
const idKey = getServiceIdKey(serviceType);
const availableServers = servers?.filter(
(s) => s.serverId !== currentServerId,
);
const selectedServer = servers?.find((s) => s.serverId === targetServerId);
// Subscription for transfer with logs
const subscriptionInput = {
[idKey]: serviceId,
targetServerId: targetServerId || "placeholder",
decisions: {},
};
const useTransferSubscription = (sType: ServiceType) => {
api.application.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "application",
onData: handleLogData,
onError: handleLogError,
});
api.compose.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "compose",
onData: handleLogData,
onError: handleLogError,
});
api.postgres.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "postgres",
onData: handleLogData,
onError: handleLogError,
});
api.mysql.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "mysql",
onData: handleLogData,
onError: handleLogError,
});
api.mariadb.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "mariadb",
onData: handleLogData,
onError: handleLogError,
});
api.mongo.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "mongo",
onData: handleLogData,
onError: handleLogError,
});
api.redis.transferWithLogs.useSubscription(subscriptionInput as any, {
enabled: isTransferring && sType === "redis",
onData: handleLogData,
onError: handleLogError,
});
};
const handleLogData = (log: string) => {
if (!isDrawerOpen) {
setIsDrawerOpen(true);
}
// Try to parse as JSON progress
try {
const progress = JSON.parse(log);
if (progress.message) {
const logLine: LogLine = {
rawTimestamp: new Date().toISOString(),
timestamp: new Date(),
message: `[${progress.phase || "transfer"}] ${progress.message}`,
};
setFilteredLogs((prev) => [...prev, logLine]);
}
return;
} catch {
// Not JSON, treat as plain text
}
const logLine: LogLine = {
rawTimestamp: new Date().toISOString(),
timestamp: new Date(),
message: log,
};
setFilteredLogs((prev) => [...prev, logLine]);
if (
log.includes("completed successfully") ||
log.includes("Deployment queued") ||
log.includes("Deployment started")
) {
setTimeout(() => {
setIsTransferring(false);
utils.invalidate();
toast.success("Transfer and deployment completed!");
}, 2000);
}
if (log.includes("Transfer failed") || log.includes("Transfer error")) {
setIsTransferring(false);
toast.error("Transfer failed");
}
};
const handleLogError = (error: unknown) => {
console.error("Transfer subscription error:", error);
setIsTransferring(false);
const logLine: LogLine = {
rawTimestamp: new Date().toISOString(),
timestamp: new Date(),
message: `Error: ${error instanceof Error ? error.message : String(error)}`,
};
setFilteredLogs((prev) => [...prev, logLine]);
};
// Register the subscription hooks (must be called unconditionally)
useTransferSubscription(serviceType);
const handleScan = async () => {
if (!targetServerId) {
toast.error("Please select a target server");
return;
}
setStep("scan");
try {
const result = await scan.mutateAsync({
[idKey]: serviceId,
targetServerId,
} as any);
setScanResult(result as ScanResult);
setStep("confirm");
} catch (error) {
toast.error(
`Scan failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
setStep("select");
}
};
const handleTransfer = async () => {
setShowConfirm(false);
setFilteredLogs([]);
setIsTransferring(true);
setIsDrawerOpen(true);
// Add initial log
setFilteredLogs([
{
rawTimestamp: new Date().toISOString(),
timestamp: new Date(),
message: `Starting transfer to ${selectedServer?.name} (${selectedServer?.ipAddress})...`,
},
]);
};
const isDbService = [
"postgres",
"mysql",
"mariadb",
"mongo",
"redis",
].includes(serviceType);
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<ArrowRightLeft className="size-5" />
Transfer Service
</CardTitle>
<CardDescription>
Transfer this {serviceType} service to a different server. Source data
is never modified or deleted.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!availableServers?.length ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Server className="size-4" />
<span>
No other servers available. Add a remote server first.
</span>
</div>
) : (
<>
{/* Step 1: Select target server */}
<div className="space-y-2">
<span className="text-sm font-medium">Target Server</span>
<Select
value={targetServerId}
onValueChange={(value) => {
setTargetServerId(value);
setScanResult(null);
setStep("select");
}}
disabled={isTransferring}
>
<SelectTrigger>
<SelectValue placeholder="Select target server" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{availableServers.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
<span className="flex items-center gap-2">
<span>{server.name}</span>
<span className="text-muted-foreground text-xs">
{server.ipAddress}
</span>
</span>
</SelectItem>
))}
<SelectLabel>
Servers ({availableServers.length})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</div>
{/* Scan button */}
{step === "select" && targetServerId && (
<Button
onClick={handleScan}
disabled={scan.isPending}
variant="outline"
>
{scan.isPending ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Scanning...
</>
) : (
"Scan for Transfer"
)}
</Button>
)}
{/* Step 2: Scan in progress */}
{step === "scan" && (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>
Scanning source and target servers for files and
conflicts...
</span>
</div>
)}
{/* Step 3: Scan results + confirm */}
{step === "confirm" && scanResult && (
<div className="space-y-4">
<div className="rounded-lg border p-4 space-y-3">
<h4 className="font-medium">Scan Results</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">
Total Files:
</span>{" "}
<span className="font-medium">
{scanResult.totalFiles}
</span>
</div>
<div>
<span className="text-muted-foreground">
Transfer Size:
</span>{" "}
<span className="font-medium">
{formatBytes(scanResult.totalTransferSize)}
</span>
</div>
<div>
<span className="text-muted-foreground">
Volumes/Mounts:
</span>{" "}
<span className="font-medium">
{scanResult.mounts.length}
</span>
</div>
<div>
<span className="text-muted-foreground">
Conflicts:
</span>{" "}
<Badge
variant={
scanResult.conflicts.length > 0
? "destructive"
: "secondary"
}
>
{scanResult.conflicts.length}
</Badge>
</div>
</div>
{scanResult.traefikConfig.exists && (
<div className="text-sm">
<span className="text-muted-foreground">
Traefik Config:
</span>{" "}
<Badge variant="outline">Will be synced</Badge>
</div>
)}
{scanResult.mounts.length > 0 && (
<div className="space-y-1">
<span className="text-sm text-muted-foreground">
Docker Volumes:
</span>
<div className="flex flex-wrap gap-1.5">
{scanResult.mounts.map((m) => (
<Badge
key={m.mount.mountId}
variant="outline"
className="font-mono text-xs"
>
{m.mount.volumeName ||
m.mount.hostPath ||
m.mount.mountPath}
{m.totalSize > 0 && (
<span className="ml-1 text-muted-foreground">
({formatBytes(m.totalSize)})
</span>
)}
{m.files.length > 0 && (
<span className="ml-1 text-muted-foreground">
{m.files.length} files
</span>
)}
</Badge>
))}
</div>
</div>
)}
</div>
{/* Conflict list */}
{scanResult.conflicts.length > 0 && (
<div className="rounded-lg border p-4 space-y-2">
<h4 className="font-medium text-sm">
File Conflicts (will be overwritten)
</h4>
<div className="max-h-40 overflow-y-auto space-y-1">
{scanResult.conflicts.map((conflict) => (
<div
key={conflict.path}
className="text-xs font-mono flex items-center gap-2"
>
<Badge
variant="outline"
className="text-[10px]"
>
{conflict.status}
</Badge>
<span className="truncate">
{conflict.path}
</span>
</div>
))}
</div>
</div>
)}
{/* Warning */}
<div className="rounded-lg border border-yellow-500/50 bg-yellow-500/10 p-4 space-y-2">
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
<AlertTriangle className="size-4" />
<span className="font-medium text-sm">
Service Downtime Warning
</span>
</div>
<p className="text-sm text-muted-foreground">
{isDbService
? "Stop the database service before transferring to avoid data corruption. After transfer completes, the service will be automatically deployed on the target server."
: "The service will be unavailable during transfer. After transfer completes, the service will be automatically deployed on the target server."}
</p>
</div>
{/* Transfer button */}
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
setStep("select");
setScanResult(null);
}}
>
Cancel
</Button>
<Button
onClick={() => setShowConfirm(true)}
disabled={isTransferring}
>
<ArrowRightLeft className="mr-2 size-4" />
Transfer to {selectedServer?.name}
</Button>
</div>
</div>
)}
</>
)}
{/* Confirmation dialog */}
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Service Transfer</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p>
You are about to transfer this {serviceType} to{" "}
<strong>{selectedServer?.name}</strong> (
{selectedServer?.ipAddress}).
</p>
{scanResult && (
<p>
{scanResult.totalFiles} files (
{formatBytes(scanResult.totalTransferSize)}) will be
copied.
{scanResult.mounts.length > 0 &&
` ${scanResult.mounts.length} volume(s) will be transferred.`}
</p>
)}
<p className="text-yellow-600 dark:text-yellow-400 font-medium">
The service will experience downtime during this
process. After transfer, the service will be
automatically deployed on the target server.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleTransfer}>
Confirm Transfer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Drawer for transfer logs */}
<DrawerLogs
isOpen={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
if (!isTransferring) {
setFilteredLogs([]);
setStep("select");
setScanResult(null);
}
}}
filteredLogs={filteredLogs}
/>
</CardContent>
</Card>
);
};

View File

@@ -1,198 +0,0 @@
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className,
)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = "ContextMenuShortcut";
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@@ -1 +0,0 @@
ALTER TABLE "user" ADD COLUMN "sendInvoiceNotifications" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -1156,13 +1156,6 @@
"when": 1775369858244,
"tag": "0164_slippery_sasquatch",
"breakpoints": true
},
{
"idx": 165,
"version": "7",
"when": 1775845419261,
"tag": "0165_abnormal_greymalkin",
"breakpoints": true
}
]
}

View File

@@ -67,7 +67,6 @@
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",

View File

@@ -53,9 +53,14 @@ export default async function handler(
if (sourceType === "github") {
const branchName = extractBranchName(req.headers, req.body);
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
const normalizedCommits =
req.body?.commits
?.flatMap((commit: any) => [
...(commit.modified || []),
...(commit.added || []),
...(commit.removed || []),
])
.filter(Boolean) || [];
const shouldDeployPaths = shouldDeploy(
composeResult.watchPaths,
@@ -73,9 +78,14 @@ export default async function handler(
}
} else if (sourceType === "gitlab") {
const branchName = extractBranchName(req.headers, req.body);
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
const normalizedCommits =
req.body?.commits
?.flatMap((commit: any) => [
...(commit.modified || []),
...(commit.added || []),
...(commit.removed || []),
])
.filter(Boolean) || [];
const shouldDeployPaths = shouldDeploy(
composeResult.watchPaths,
@@ -124,17 +134,32 @@ export default async function handler(
let normalizedCommits: string[] = [];
if (provider === "github") {
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
normalizedCommits =
req.body?.commits
?.flatMap((commit: any) => [
...(commit.modified || []),
...(commit.added || []),
...(commit.removed || []),
])
.filter(Boolean) || [];
} else if (provider === "gitlab") {
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
normalizedCommits =
req.body?.commits
?.flatMap((commit: any) => [
...(commit.modified || []),
...(commit.added || []),
...(commit.removed || []),
])
.filter(Boolean) || [];
} else if (provider === "gitea") {
normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
normalizedCommits =
req.body?.commits
?.flatMap((commit: any) => [
...(commit.modified || []),
...(commit.added || []),
...(commit.removed || []),
])
.filter(Boolean) || [];
}
const shouldDeployPaths = shouldDeploy(
@@ -149,9 +174,14 @@ export default async function handler(
} else if (sourceType === "gitea") {
const branchName = extractBranchName(req.headers, req.body);
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
const normalizedCommits =
req.body?.commits
?.flatMap((commit: any) => [
...(commit.modified || []),
...(commit.added || []),
...(commit.removed || []),
])
.filter(Boolean) || [];
const shouldDeployPaths = shouldDeploy(
composeResult.watchPaths,

View File

@@ -213,9 +213,14 @@ export default async function handler(
const deploymentTitle = extractCommitMessage(req.headers, req.body);
const deploymentHash = extractHash(req.headers, req.body);
const owner = githubBody?.repository?.owner?.name;
const normalizedCommits = githubBody?.commits?.flatMap(
(commit: any) => commit.modified,
);
const normalizedCommits =
githubBody?.commits
?.flatMap((commit: any) => [
...(commit.modified || []),
...(commit.added || []),
...(commit.removed || []),
])
.filter(Boolean) || [];
const apps = await db.query.applications.findMany({
where: and(

View File

@@ -5,10 +5,6 @@ import { and, asc, eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe";
import { organization, server, user } from "@/server/db/schema";
import {
sendInvoiceEmail,
sendPaymentFailedEmail,
} from "@/server/utils/stripe-notifications";
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
@@ -245,11 +241,6 @@ export default async function handler(
}
const newServersQuantity = admin.serversQuantity;
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
if (admin.sendInvoiceNotifications) {
await sendInvoiceEmail(newInvoice, admin);
}
break;
}
case "invoice.payment_failed": {
@@ -258,6 +249,7 @@ export default async function handler(
const subscription = await stripe.subscriptions.retrieve(
newInvoice.subscription as string,
);
if (subscription.status !== "active") {
const admin = await findUserByStripeCustomerId(
newInvoice.customer as string,
@@ -271,10 +263,6 @@ export default async function handler(
break;
}
if (admin.sendInvoiceNotifications) {
await sendPaymentFailedEmail(newInvoice, admin);
}
await db
.update(user)
.set({

View File

@@ -40,7 +40,7 @@ function DeploymentsPage() {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl min-h-[45vh]">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-8xl mx-auto min-h-[45vh]">
<div className="rounded-xl bg-background shadow-md h-full">
<CardHeader>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">

View File

@@ -12,7 +12,6 @@ import {
Loader2,
Play,
PlusIcon,
RefreshCw,
Search,
ServerIcon,
SquareTerminal,
@@ -69,14 +68,6 @@ import {
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
Dialog,
DialogContent,
@@ -433,7 +424,6 @@ const EnvironmentPage = (
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [deleteVolumes, setDeleteVolumes] = useState(false);
const [selectedServerId, setSelectedServerId] = useState<string>("all");
const [serviceToDelete, setServiceToDelete] = useState<Services | null>(null);
const handleSelectAll = () => {
if (selectedServices.length === filteredServices.length) {
@@ -824,110 +814,6 @@ const EnvironmentPage = (
setIsBulkActionLoading(false);
};
const getServiceActions = (service: Services) => {
switch (service.type) {
case "application":
return applicationActions;
case "compose":
return composeActions;
case "postgres":
return postgresActions;
case "mysql":
return mysqlActions;
case "mariadb":
return mariadbActions;
case "redis":
return redisActions;
case "mongo":
return mongoActions;
default:
return null;
}
};
const getServiceIdKey = (service: Services) => {
switch (service.type) {
case "application":
return "applicationId";
case "compose":
return "composeId";
case "postgres":
return "postgresId";
case "mysql":
return "mysqlId";
case "mariadb":
return "mariadbId";
case "redis":
return "redisId";
case "mongo":
return "mongoId";
default:
return null;
}
};
const handleServiceAction = async (
service: Services,
action: "start" | "stop" | "deploy",
) => {
const actions = getServiceActions(service);
const idKey = getServiceIdKey(service);
if (!actions || !idKey) return;
const actionLabels = {
start: { loading: "Starting", success: "started", error: "starting" },
stop: { loading: "Stopping", success: "stopped", error: "stopping" },
deploy: {
loading: "Deploying",
success: "queued for deployment",
error: "deploying",
},
};
const labels = actionLabels[action];
toast.promise(
(async () => {
await actions[action].mutateAsync({
[idKey]: service.id,
} as any);
})(),
{
loading: `${labels.loading} ${service.name}...`,
success: () => {
utils.environment.one.invalidate({ environmentId });
return `${service.name} ${labels.success} successfully`;
},
error: (error) =>
`Error ${labels.error} ${service.name}: ${error instanceof Error ? error.message : "Unknown error"}`,
},
);
};
const handleServiceDelete = async (service: Services) => {
const actions = getServiceActions(service);
const idKey = getServiceIdKey(service);
if (!actions || !idKey) return;
toast.promise(
(async () => {
await actions.delete.mutateAsync({
[idKey]: service.id,
} as any);
})(),
{
loading: `Deleting ${service.name}...`,
success: () => {
utils.environment.one.invalidate({ environmentId });
return `${service.name} deleted successfully`;
},
error: (error) =>
`Error deleting ${service.name}: ${error instanceof Error ? error.message : "Unknown error"}`,
},
);
setServiceToDelete(null);
};
// Get unique servers from services
const availableServers = useMemo(() => {
if (!applications) return [];
@@ -1586,156 +1472,110 @@ const EnvironmentPage = (
<div className="flex w-full flex-col gap-4">
<div className="gap-5 pb-10 grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
{filteredServices?.map((service) => (
<ContextMenu key={service.id}>
<ContextMenuTrigger asChild>
<Link
href={`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`}
className="block"
<Link
key={service.id}
href={`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`}
className="block"
>
<Card className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border">
{service.serverId && (
<div className="absolute -left-1 -top-2">
<ServerIcon className="size-4 text-muted-foreground" />
</div>
)}
<div className="absolute -right-1 -top-2">
<StatusTooltip status={service.status} />
</div>
<div
className={cn(
"absolute -left-3 -bottom-3 size-9 translate-y-1 rounded-full p-0 transition-all duration-200 z-10 bg-background border",
selectedServices.includes(service.id)
? "opacity-100 translate-y-0"
: "opacity-0 group-hover:translate-y-0 group-hover:opacity-100",
)}
onClick={(e) =>
handleServiceSelect(service.id, e)
}
>
<Card className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border">
{service.serverId && (
<div className="absolute -left-1 -top-2">
<ServerIcon className="size-4 text-muted-foreground" />
<div className="h-full w-full flex items-center justify-center">
<Checkbox
checked={selectedServices.includes(
service.id,
)}
className="data-[state=checked]:bg-primary"
/>
</div>
</div>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex flex-row items-center gap-2 justify-between w-full">
<div className="flex flex-col gap-2">
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap">
{service.name}
</span>
{service.description && (
<span className="text-sm font-medium text-muted-foreground">
{service.description}
</span>
)}
</div>
<span className="text-sm font-medium text-muted-foreground self-start">
{service.type === "postgres" && (
<PostgresqlIcon className="h-7 w-7" />
)}
{service.type === "redis" && (
<RedisIcon className="h-7 w-7" />
)}
{service.type === "mariadb" && (
<MariadbIcon className="h-7 w-7" />
)}
{service.type === "mongo" && (
<MongodbIcon className="h-7 w-7" />
)}
{service.type === "mysql" && (
<MysqlIcon className="h-7 w-7" />
)}
{service.type === "application" &&
(service.icon ? (
// biome-ignore lint/performance/noImgElement: application icon is data URL
<img
src={service.icon}
alt={service.name}
className="size-7 object-contain"
/>
) : (
<GlobeIcon className="h-6 w-6" />
))}
{service.type === "compose" && (
<CircuitBoard className="h-6 w-6" />
)}
{service.type === "libsql" && (
<LibsqlIcon className="h-6 w-6" />
)}
</span>
</div>
</CardTitle>
</CardHeader>
<CardFooter className="mt-auto">
<div className="space-y-1 text-sm w-full">
{service.serverName && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<ServerIcon className="size-3" />
<span className="truncate">
{service.serverName}
</span>
</div>
)}
<div className="absolute -right-1 -top-2">
<StatusTooltip status={service.status} />
</div>
<div
className={cn(
"absolute -left-3 -bottom-3 size-9 translate-y-1 rounded-full p-0 transition-all duration-200 z-10 bg-background border",
selectedServices.includes(service.id)
? "opacity-100 translate-y-0"
: "opacity-0 group-hover:translate-y-0 group-hover:opacity-100",
)}
onClick={(e) =>
handleServiceSelect(service.id, e)
}
>
<div className="h-full w-full flex items-center justify-center">
<Checkbox
checked={selectedServices.includes(
service.id,
)}
className="data-[state=checked]:bg-primary"
/>
</div>
</div>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex flex-row items-center gap-2 justify-between w-full">
<div className="flex flex-col gap-2">
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap">
{service.name}
</span>
{service.description && (
<span className="text-sm font-medium text-muted-foreground">
{service.description}
</span>
)}
</div>
<span className="text-sm font-medium text-muted-foreground self-start">
{service.type === "postgres" && (
<PostgresqlIcon className="h-7 w-7" />
)}
{service.type === "redis" && (
<RedisIcon className="h-7 w-7" />
)}
{service.type === "mariadb" && (
<MariadbIcon className="h-7 w-7" />
)}
{service.type === "mongo" && (
<MongodbIcon className="h-7 w-7" />
)}
{service.type === "mysql" && (
<MysqlIcon className="h-7 w-7" />
)}
{service.type === "application" &&
(service.icon ? (
// biome-ignore lint/performance/noImgElement: application icon is data URL
<img
src={service.icon}
alt={service.name}
className="size-7 object-contain"
/>
) : (
<GlobeIcon className="h-6 w-6" />
))}
{service.type === "compose" && (
<CircuitBoard className="h-6 w-6" />
)}
{service.type === "libsql" && (
<LibsqlIcon className="h-6 w-6" />
)}
</span>
</div>
</CardTitle>
</CardHeader>
<CardFooter className="mt-auto">
<div className="space-y-1 text-sm w-full">
{service.serverName && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<ServerIcon className="size-3" />
<span className="truncate">
{service.serverName}
</span>
</div>
)}
<DateTooltip date={service.createdAt}>
Created
</DateTooltip>
</div>
</CardFooter>
</Card>
</Link>
</ContextMenuTrigger>
{service.type !== "libsql" && (
<ContextMenuContent className="w-48">
<ContextMenuLabel className="truncate">
{service.name}
</ContextMenuLabel>
<ContextMenuSeparator />
<ContextMenuItem
className="flex items-center gap-2"
onClick={() =>
handleServiceAction(service, "start")
}
>
<Play className="size-4" />
Start
</ContextMenuItem>
<ContextMenuItem
className="flex items-center gap-2"
onClick={() =>
handleServiceAction(service, "deploy")
}
>
<RefreshCw className="size-4" />
Deploy
</ContextMenuItem>
<ContextMenuItem
className="flex items-center gap-2 text-orange-500 focus:text-orange-500"
onClick={() =>
handleServiceAction(service, "stop")
}
>
<Ban className="size-4" />
Stop
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
className="flex items-center gap-2 text-red-500 focus:text-red-500"
onClick={() => setServiceToDelete(service)}
>
<Trash2 className="size-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
<DateTooltip date={service.createdAt}>
Created
</DateTooltip>
</div>
</CardFooter>
</Card>
</Link>
))}
</div>
</div>
@@ -1746,38 +1586,6 @@ const EnvironmentPage = (
</div>
</Card>
</div>
{/* Single Service Delete Dialog */}
<Dialog
open={!!serviceToDelete}
onOpenChange={(open) => !open && setServiceToDelete(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Service</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold">{serviceToDelete?.name}</span>?
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setServiceToDelete(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (serviceToDelete) {
handleServiceDelete(serviceToDelete);
}
}}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};

View File

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

View File

@@ -22,8 +22,6 @@ import { ShowSchedules } from "@/components/dashboard/application/schedules/show
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
import { TransferService } from "@/components/dashboard/shared/transfer-service";
import { ShowComposeContainers } from "@/components/dashboard/compose/containers/show-compose-containers";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
@@ -62,7 +60,6 @@ type TabState =
| "advanced"
| "deployments"
| "domains"
| "containers"
| "monitoring"
| "volumeBackups";
@@ -234,9 +231,6 @@ const Service = (
Deployments
</TabsTrigger>
)}
{permissions?.service.read && (
<TabsTrigger value="containers">Containers</TabsTrigger>
)}
{permissions?.service.create && (
<TabsTrigger value="backups">Backups</TabsTrigger>
)}
@@ -304,18 +298,6 @@ const Service = (
</div>
</TabsContent>
)}
{permissions?.service.read && (
<TabsContent value="containers">
<div className="flex flex-col gap-4 pt-2.5">
<ShowComposeContainers
serverId={data?.serverId || undefined}
appName={data?.appName || ""}
appType={data?.composeType || "docker-compose"}
/>
</div>
</TabsContent>
)}
{permissions?.monitoring.read && (
<TabsContent value="monitoring">
<div className="pt-2.5">
@@ -424,11 +406,6 @@ const Service = (
<ShowVolumes id={composeId} type="compose" />
<ShowImport composeId={composeId} />
<IsolatedDeploymentTab composeId={composeId} />
<TransferService
serviceId={composeId}
serviceType="compose"
currentServerId={data?.serverId ?? null}
/>
</div>
</TabsContent>
)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,11 +25,9 @@ import { findProjectById } from "@dokploy/server/services/project";
import {
getProviderHeaders,
getProviderName,
selectAIProvider,
type Model,
} from "@dokploy/server/utils/ai/select-ai-provider";
import { TRPCError } from "@trpc/server";
import { generateText } from "ai";
import { z } from "zod";
import { slugify } from "@/lib/slug";
import {
@@ -97,30 +95,6 @@ export const aiRouter = createTRPCRouter({
owned_by: "perplexity",
},
] as Model[];
case "zai":
return [
{
id: "glm-5",
object: "model",
created: Date.now(),
owned_by: "zai",
},
{
id: "glm-4.7",
object: "model",
created: Date.now(),
owned_by: "zai",
},
] as Model[];
case "minimax":
return [
{
id: "MiniMax-M2.7",
object: "model",
created: Date.now(),
owned_by: "minimax",
},
] as Model[];
default:
if (!input.apiKey)
throw new TRPCError({
@@ -200,107 +174,6 @@ export const aiRouter = createTRPCRouter({
return await deleteAiSettings(input.aiId);
}),
getEnabledProviders: protectedProcedure.query(async ({ ctx }) => {
const settings = await getAiSettingsByOrganizationId(
ctx.session.activeOrganizationId,
);
return settings
.filter((s) => s.isEnabled)
.map((s) => ({ aiId: s.aiId, name: s.name, model: s.model }));
}),
analyzeLogs: protectedProcedure
.input(
z.object({
aiId: z.string().min(1),
logs: z.string().min(1),
context: z.enum(["build", "runtime"]),
}),
)
.mutation(async ({ input, ctx }) => {
try {
const aiSettings = await getAiSettingById(input.aiId);
if (!aiSettings?.isEnabled) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "AI provider is not enabled",
});
}
if (aiSettings.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Access denied",
});
}
const provider = selectAIProvider(aiSettings);
const model = provider(aiSettings.model);
const contextLabel =
input.context === "build" ? "build/deployment" : "runtime/container";
const result = await generateText({
model,
prompt: `You are a DevOps engineer analyzing ${contextLabel} logs. Analyze the following logs and provide:
1. **Summary**: A brief summary of what's happening
2. **Issues Found**: Any errors, warnings, or problems detected
3. **Root Cause**: The most likely root cause if there are errors
4. **Suggested Fix**: Actionable steps to resolve the issues
Be concise and practical. Focus on the most important issues. If the logs look healthy, say so briefly.
Logs:
${input.logs}`,
});
return { analysis: result.text };
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: `Analysis failed: ${error}`,
});
}
}),
testConnection: protectedProcedure
.input(
z.object({
apiUrl: z.string().min(1),
apiKey: z.string(),
model: z.string().min(1),
}),
)
.mutation(async ({ input }) => {
try {
const provider = selectAIProvider({
apiUrl: input.apiUrl,
apiKey: input.apiKey,
});
const model = provider(input.model);
const result = await generateText({
model,
prompt: "Reply with 'ok'",
});
if (!result.text) {
throw new Error("No response received from the model");
}
return { success: true, message: "Connection successful" };
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: `Connection failed: ${error}`,
});
}
}),
suggest: protectedProcedure
.input(
z.object({

View File

@@ -6,9 +6,7 @@ import {
findEnvironmentById,
findGitProviderById,
findProjectById,
getAccessibleServerIds,
getApplicationStats,
getContainerLogs,
IS_CLOUD,
mechanizeDockerContainer,
readConfig,
@@ -28,8 +26,7 @@ import {
updateDeploymentStatus,
writeConfig,
writeConfigRemote,
scanServiceForTransfer,
executeTransfer,
getAccessibleServerIds,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -64,7 +61,6 @@ import {
apiSaveGithubProvider,
apiSaveGitlabProvider,
apiSaveGitProvider,
apiTransferApplication,
apiUpdateApplication,
applications,
environments,
@@ -1105,215 +1101,4 @@ export const applicationRouter = createTRPCRouter({
total: countResult[0]?.count ?? 0,
};
}),
readLogs: protectedProcedure
.input(
apiFindOneApplication.extend({
tail: z.number().int().min(1).max(10000).default(100),
since: z
.string()
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
.default("all"),
search: z
.string()
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
.optional(),
}),
)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.applicationId, "read");
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return await getContainerLogs(
application.appName,
input.tail,
input.since,
input.search,
application.serverId,
);
}),
transferScan: protectedProcedure
.input(apiTransferApplication.pick({ applicationId: true, targetServerId: true }))
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["delete"],
});
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
return await scanServiceForTransfer({
serviceId: input.applicationId,
serviceType: "application",
appName: application.appName,
sourceServerId: application.serverId,
targetServerId: input.targetServerId,
});
}),
transferWithLogs: protectedProcedure
.input(apiTransferApplication)
.subscription(async function* ({ input, ctx, signal }) {
const application = await findApplicationById(input.applicationId);
await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["delete"],
});
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
const queue: string[] = [];
let done = false;
executeTransfer(
{
serviceId: input.applicationId,
serviceType: "application",
appName: application.appName,
sourceServerId: application.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
(progress) => {
queue.push(JSON.stringify(progress));
},
)
.then(async (result) => {
if (result.success) {
await db
.update(applications)
.set({ serverId: input.targetServerId })
.where(eq(applications.applicationId, input.applicationId));
queue.push("Transfer completed! Starting deployment on target server...");
// Auto-deploy on target server
const jobData: DeploymentJob = {
applicationId: input.applicationId,
titleLog: "Transfer deployment",
type: "deploy",
applicationType: "application",
descriptionLog: "Auto-deploy after transfer to new server",
server: true,
};
if (IS_CLOUD) {
jobData.serverId = input.targetServerId;
deploy(jobData).catch(() => {});
} else {
await myQueue.add("deployments", jobData, {
removeOnComplete: true,
removeOnFail: true,
});
}
queue.push("Deployment queued successfully!");
} else {
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
}
})
.catch((error) => {
queue.push(
`Transfer error: ${error instanceof Error ? error.message : String(error)}`,
);
})
.finally(() => {
done = true;
});
while (!done || queue.length > 0) {
if (queue.length > 0) {
yield queue.shift()!;
} else {
await new Promise((r) => setTimeout(r, 50));
}
if (signal?.aborted) {
return;
}
}
}),
transfer: protectedProcedure
.input(apiTransferApplication)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
await checkServicePermissionAndAccess(ctx, input.applicationId, {
service: ["delete"],
});
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
const result = await executeTransfer(
{
serviceId: input.applicationId,
serviceType: "application",
appName: application.appName,
sourceServerId: application.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Transfer failed: ${result.errors.join(", ")}`,
});
}
await db
.update(applications)
.set({ serverId: input.targetServerId })
.where(eq(applications.applicationId, input.applicationId));
// Auto-deploy on target server
const jobData: DeploymentJob = {
applicationId: input.applicationId,
titleLog: "Transfer deployment",
type: "deploy",
applicationType: "application",
descriptionLog: "Auto-deploy after transfer to new server",
server: true,
};
if (IS_CLOUD) {
jobData.serverId = input.targetServerId;
deploy(jobData).catch(() => {});
} else {
await myQueue.add("deployments", jobData, {
removeOnComplete: true,
removeOnFail: true,
});
}
return { success: true };
}),
});

View File

@@ -16,9 +16,7 @@ import {
findGitProviderById,
findProjectById,
findServerById,
getAccessibleServerIds,
getComposeContainer,
getContainerLogs,
getWebServerSettings,
IS_CLOUD,
loadServices,
@@ -32,8 +30,7 @@ import {
stopCompose,
updateCompose,
updateDeploymentStatus,
scanServiceForTransfer,
executeTransfer,
getAccessibleServerIds,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -64,8 +61,6 @@ import {
apiFindCompose,
apiRandomizeCompose,
apiRedeployCompose,
apiSaveEnvironmentVariablesCompose,
apiTransferCompose,
apiUpdateCompose,
compose as composeTable,
environments,
@@ -206,31 +201,6 @@ export const composeRouter = createTRPCRouter({
});
return updated;
}),
saveEnvironment: protectedProcedure
.input(apiSaveEnvironmentVariablesCompose)
.mutation(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
envVars: ["write"],
});
const updated = await updateCompose(input.composeId, {
env: input.env,
});
if (!updated) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error adding environment variables",
});
}
await audit(ctx, {
action: "update",
resourceType: "compose",
resourceId: input.composeId,
resourceName: updated?.name,
});
return true;
}),
delete: protectedProcedure
.input(apiDeleteCompose)
.mutation(async ({ input, ctx }) => {
@@ -320,7 +290,7 @@ export const composeRouter = createTRPCRouter({
.input(apiFetchServices)
.query(async ({ input, ctx }) => {
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["read"],
service: ["create"],
});
return await loadServices(input.composeId, input.type);
}),
@@ -1134,219 +1104,4 @@ export const composeRouter = createTRPCRouter({
total: countResult[0]?.count ?? 0,
};
}),
readLogs: protectedProcedure
.input(
apiFindCompose.extend({
containerId: z
.string()
.min(1)
.regex(/^[a-zA-Z0-9.\-_]+$/, "Invalid container id."),
tail: z.number().int().min(1).max(10000).default(100),
since: z
.string()
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
.default("all"),
search: z
.string()
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
.optional(),
}),
)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.composeId, "read");
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
return await getContainerLogs(
input.containerId,
input.tail,
input.since,
input.search,
compose.serverId,
true,
);
}),
transferScan: protectedProcedure
.input(apiTransferCompose.pick({ composeId: true, targetServerId: true }))
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["delete"],
});
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
return await scanServiceForTransfer({
serviceId: input.composeId,
serviceType: "compose",
appName: compose.appName,
sourceServerId: compose.serverId,
targetServerId: input.targetServerId,
});
}),
transferWithLogs: protectedProcedure
.input(apiTransferCompose)
.subscription(async function* ({ input, ctx, signal }) {
const compose = await findComposeById(input.composeId);
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["delete"],
});
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
const queue: string[] = [];
let done = false;
executeTransfer(
{
serviceId: input.composeId,
serviceType: "compose",
appName: compose.appName,
sourceServerId: compose.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
(progress) => {
queue.push(JSON.stringify(progress));
},
)
.then(async (result) => {
if (result.success) {
await db
.update(composeTable)
.set({ serverId: input.targetServerId })
.where(eq(composeTable.composeId, input.composeId));
queue.push("Transfer completed! Starting deployment on target server...");
const jobData: DeploymentJob = {
composeId: input.composeId,
titleLog: "Transfer deployment",
type: "deploy",
applicationType: "compose",
descriptionLog: "Auto-deploy after transfer to new server",
server: true,
};
if (IS_CLOUD) {
jobData.serverId = input.targetServerId;
deploy(jobData).catch(() => {});
} else {
await myQueue.add("deployments", jobData, {
removeOnComplete: true,
removeOnFail: true,
});
}
queue.push("Deployment queued successfully!");
} else {
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
}
})
.catch((error) => {
queue.push(
`Transfer error: ${error instanceof Error ? error.message : String(error)}`,
);
})
.finally(() => {
done = true;
});
while (!done || queue.length > 0) {
if (queue.length > 0) {
yield queue.shift()!;
} else {
await new Promise((r) => setTimeout(r, 50));
}
if (signal?.aborted) {
return;
}
}
}),
transfer: protectedProcedure
.input(apiTransferCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
await checkServicePermissionAndAccess(ctx, input.composeId, {
service: ["delete"],
});
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
const result = await executeTransfer(
{
serviceId: input.composeId,
serviceType: "compose",
appName: compose.appName,
sourceServerId: compose.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Transfer failed: ${result.errors.join(", ")}`,
});
}
await db
.update(composeTable)
.set({ serverId: input.targetServerId })
.where(eq(composeTable.composeId, input.composeId));
// Auto-deploy on target server
const jobData: DeploymentJob = {
composeId: input.composeId,
titleLog: "Transfer deployment",
type: "deploy",
applicationType: "compose",
descriptionLog: "Auto-deploy after transfer to new server",
server: true,
};
if (IS_CLOUD) {
jobData.serverId = input.targetServerId;
deploy(jobData).catch(() => {});
} else {
await myQueue.add("deployments", jobData, {
removeOnComplete: true,
removeOnFail: true,
});
}
return { success: true };
}),
});

View File

@@ -1,9 +1,6 @@
import {
containerKill,
containerRemove,
containerRestart,
containerStart,
containerStop,
findServerById,
getConfig,
getContainers,
@@ -38,108 +35,24 @@ export const dockerRouter = createTRPCRouter({
return await getContainers(input.serverId);
}),
restartContainer: withPermission("service", "read")
restartContainer: withPermission("docker", "read")
.input(
z.object({
containerId: z
.string()
.min(1)
.regex(containerIdRegex, "Invalid container id."),
serverId: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
await containerRestart(input.containerId, input.serverId);
const result = await containerRestart(input.containerId);
await audit(ctx, {
action: "start",
resourceType: "docker",
resourceId: input.containerId,
resourceName: input.containerId,
});
}),
startContainer: withPermission("service", "read")
.input(
z.object({
containerId: z
.string()
.min(1)
.regex(containerIdRegex, "Invalid container id."),
serverId: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
await containerStart(input.containerId, input.serverId);
await audit(ctx, {
action: "start",
resourceType: "docker",
resourceId: input.containerId,
resourceName: input.containerId,
});
}),
stopContainer: withPermission("service", "read")
.input(
z.object({
containerId: z
.string()
.min(1)
.regex(containerIdRegex, "Invalid container id."),
serverId: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
await containerStop(input.containerId, input.serverId);
await audit(ctx, {
action: "stop",
resourceType: "docker",
resourceId: input.containerId,
resourceName: input.containerId,
});
}),
killContainer: withPermission("service", "read")
.input(
z.object({
containerId: z
.string()
.min(1)
.regex(containerIdRegex, "Invalid container id."),
serverId: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
await containerKill(input.containerId, input.serverId);
await audit(ctx, {
action: "stop",
resourceType: "docker",
resourceId: input.containerId,
resourceName: input.containerId,
});
return result;
}),
removeContainer: withPermission("docker", "read")

View File

@@ -6,7 +6,6 @@ import {
findEnvironmentById,
findLibsqlById,
findProjectById,
getContainerLogs,
IS_CLOUD,
rebuildDatabase,
removeLibsqlById,
@@ -467,39 +466,4 @@ export const libsqlRouter = createTRPCRouter({
});
return true;
}),
readLogs: protectedProcedure
.input(
apiFindOneLibsql.extend({
tail: z.number().int().min(1).max(10000).default(100),
since: z
.string()
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
.default("all"),
search: z
.string()
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
.optional(),
}),
)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.libsqlId, "read");
const libsql = await findLibsqlById(input.libsqlId);
if (
libsql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this LibSQL",
});
}
return await getContainerLogs(
libsql.appName,
input.tail,
input.since,
input.search,
libsql.serverId,
);
}),
});

View File

@@ -9,8 +9,6 @@ import {
findEnvironmentById,
findMariadbById,
findProjectById,
getAccessibleServerIds,
getContainerLogs,
getServiceContainerCommand,
IS_CLOUD,
rebuildDatabase,
@@ -21,8 +19,7 @@ import {
stopService,
stopServiceRemote,
updateMariadbById,
scanServiceForTransfer,
executeTransfer,
getAccessibleServerIds,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -46,7 +43,6 @@ import {
apiResetMariadb,
apiSaveEnvironmentVariablesMariaDB,
apiSaveExternalPortMariaDB,
apiTransferMariadb,
apiUpdateMariaDB,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
@@ -594,160 +590,4 @@ export const mariadbRouter = createTRPCRouter({
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
readLogs: protectedProcedure
.input(
apiFindOneMariaDB.extend({
tail: z.number().int().min(1).max(10000).default(100),
since: z
.string()
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
.default("all"),
search: z
.string()
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
.optional(),
}),
)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.mariadbId, "read");
const mariadb = await findMariadbById(input.mariadbId);
if (
mariadb.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MariaDB",
});
}
return await getContainerLogs(
mariadb.appName,
input.tail,
input.since,
input.search,
mariadb.serverId,
);
}),
transferScan: protectedProcedure
.input(apiTransferMariadb.pick({ mariadbId: true, targetServerId: true }))
.mutation(async ({ input, ctx }) => {
const mariadb = await findMariadbById(input.mariadbId);
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
service: ["delete"],
});
if (
mariadb.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MariaDB",
});
}
return await scanServiceForTransfer({
serviceId: input.mariadbId,
serviceType: "mariadb",
appName: mariadb.appName,
sourceServerId: mariadb.serverId,
targetServerId: input.targetServerId,
});
}),
transferWithLogs: protectedProcedure
.input(apiTransferMariadb)
.subscription(async function* ({ input, ctx, signal }) {
const mariadb = await findMariadbById(input.mariadbId);
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
service: ["delete"],
});
if (
mariadb.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MariaDB",
});
}
const queue: string[] = [];
let done = false;
executeTransfer(
{
serviceId: input.mariadbId,
serviceType: "mariadb",
appName: mariadb.appName,
sourceServerId: mariadb.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
(progress) => { queue.push(JSON.stringify(progress)); },
)
.then(async (result) => {
if (result.success) {
await db
.update(mariadbTable)
.set({ serverId: input.targetServerId })
.where(eq(mariadbTable.mariadbId, input.mariadbId));
queue.push("Transfer completed! Starting deployment on target server...");
await deployMariadb(input.mariadbId).catch(() => {});
queue.push("Deployment started!");
} else {
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
}
})
.catch((error) => {
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
})
.finally(() => { done = true; });
while (!done || queue.length > 0) {
if (queue.length > 0) { yield queue.shift()!; }
else { await new Promise((r) => setTimeout(r, 50)); }
if (signal?.aborted) { return; }
}
}),
transfer: protectedProcedure
.input(apiTransferMariadb)
.mutation(async ({ input, ctx }) => {
const mariadb = await findMariadbById(input.mariadbId);
await checkServicePermissionAndAccess(ctx, input.mariadbId, {
service: ["delete"],
});
if (
mariadb.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MariaDB",
});
}
const result = await executeTransfer(
{
serviceId: input.mariadbId,
serviceType: "mariadb",
appName: mariadb.appName,
sourceServerId: mariadb.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Transfer failed: ${result.errors.join(", ")}`,
});
}
await db
.update(mariadbTable)
.set({ serverId: input.targetServerId })
.where(eq(mariadbTable.mariadbId, input.mariadbId));
await deployMariadb(input.mariadbId).catch(() => {});
return { success: true };
}),
});

View File

@@ -10,7 +10,6 @@ import {
findMongoById,
findProjectById,
getAccessibleServerIds,
getContainerLogs,
getServiceContainerCommand,
IS_CLOUD,
rebuildDatabase,
@@ -21,8 +20,6 @@ import {
stopService,
stopServiceRemote,
updateMongoById,
scanServiceForTransfer,
executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -45,7 +42,6 @@ import {
apiResetMongo,
apiSaveEnvironmentVariablesMongo,
apiSaveExternalPortMongo,
apiTransferMongo,
apiUpdateMongo,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
@@ -605,160 +601,4 @@ export const mongoRouter = createTRPCRouter({
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
readLogs: protectedProcedure
.input(
apiFindOneMongo.extend({
tail: z.number().int().min(1).max(10000).default(100),
since: z
.string()
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
.default("all"),
search: z
.string()
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
.optional(),
}),
)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.mongoId, "read");
const mongo = await findMongoById(input.mongoId);
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MongoDB",
});
}
return await getContainerLogs(
mongo.appName,
input.tail,
input.since,
input.search,
mongo.serverId,
);
}),
transferScan: protectedProcedure
.input(apiTransferMongo.pick({ mongoId: true, targetServerId: true }))
.mutation(async ({ input, ctx }) => {
const mongo = await findMongoById(input.mongoId);
await checkServicePermissionAndAccess(ctx, input.mongoId, {
service: ["delete"],
});
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MongoDB",
});
}
return await scanServiceForTransfer({
serviceId: input.mongoId,
serviceType: "mongo",
appName: mongo.appName,
sourceServerId: mongo.serverId,
targetServerId: input.targetServerId,
});
}),
transferWithLogs: protectedProcedure
.input(apiTransferMongo)
.subscription(async function* ({ input, ctx, signal }) {
const mongo = await findMongoById(input.mongoId);
await checkServicePermissionAndAccess(ctx, input.mongoId, {
service: ["delete"],
});
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MongoDB",
});
}
const queue: string[] = [];
let done = false;
executeTransfer(
{
serviceId: input.mongoId,
serviceType: "mongo",
appName: mongo.appName,
sourceServerId: mongo.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
(progress) => { queue.push(JSON.stringify(progress)); },
)
.then(async (result) => {
if (result.success) {
await db
.update(mongoTable)
.set({ serverId: input.targetServerId })
.where(eq(mongoTable.mongoId, input.mongoId));
queue.push("Transfer completed! Starting deployment on target server...");
await deployMongo(input.mongoId).catch(() => {});
queue.push("Deployment started!");
} else {
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
}
})
.catch((error) => {
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
})
.finally(() => { done = true; });
while (!done || queue.length > 0) {
if (queue.length > 0) { yield queue.shift()!; }
else { await new Promise((r) => setTimeout(r, 50)); }
if (signal?.aborted) { return; }
}
}),
transfer: protectedProcedure
.input(apiTransferMongo)
.mutation(async ({ input, ctx }) => {
const mongo = await findMongoById(input.mongoId);
await checkServicePermissionAndAccess(ctx, input.mongoId, {
service: ["delete"],
});
if (
mongo.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MongoDB",
});
}
const result = await executeTransfer(
{
serviceId: input.mongoId,
serviceType: "mongo",
appName: mongo.appName,
sourceServerId: mongo.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Transfer failed: ${result.errors.join(", ")}`,
});
}
await db
.update(mongoTable)
.set({ serverId: input.targetServerId })
.where(eq(mongoTable.mongoId, input.mongoId));
await deployMongo(input.mongoId).catch(() => {});
return { success: true };
}),
});

View File

@@ -9,7 +9,6 @@ import {
findEnvironmentById,
findMySqlById,
findProjectById,
getContainerLogs,
getServiceContainerCommand,
IS_CLOUD,
rebuildDatabase,
@@ -21,8 +20,6 @@ import {
stopServiceRemote,
updateMySqlById,
getAccessibleServerIds,
scanServiceForTransfer,
executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -45,7 +42,6 @@ import {
apiResetMysql,
apiSaveEnvironmentVariablesMySql,
apiSaveExternalPortMySql,
apiTransferMysql,
apiUpdateMySql,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
@@ -608,160 +604,4 @@ export const mysqlRouter = createTRPCRouter({
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
readLogs: protectedProcedure
.input(
apiFindOneMySql.extend({
tail: z.number().int().min(1).max(10000).default(100),
since: z
.string()
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
.default("all"),
search: z
.string()
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
.optional(),
}),
)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.mysqlId, "read");
const mysql = await findMySqlById(input.mysqlId);
if (
mysql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MySQL",
});
}
return await getContainerLogs(
mysql.appName,
input.tail,
input.since,
input.search,
mysql.serverId,
);
}),
transferScan: protectedProcedure
.input(apiTransferMysql.pick({ mysqlId: true, targetServerId: true }))
.mutation(async ({ input, ctx }) => {
const mysql = await findMySqlById(input.mysqlId);
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
service: ["delete"],
});
if (
mysql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MySQL",
});
}
return await scanServiceForTransfer({
serviceId: input.mysqlId,
serviceType: "mysql",
appName: mysql.appName,
sourceServerId: mysql.serverId,
targetServerId: input.targetServerId,
});
}),
transferWithLogs: protectedProcedure
.input(apiTransferMysql)
.subscription(async function* ({ input, ctx, signal }) {
const mysql = await findMySqlById(input.mysqlId);
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
service: ["delete"],
});
if (
mysql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MySQL",
});
}
const queue: string[] = [];
let done = false;
executeTransfer(
{
serviceId: input.mysqlId,
serviceType: "mysql",
appName: mysql.appName,
sourceServerId: mysql.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
(progress) => { queue.push(JSON.stringify(progress)); },
)
.then(async (result) => {
if (result.success) {
await db
.update(mysqlTable)
.set({ serverId: input.targetServerId })
.where(eq(mysqlTable.mysqlId, input.mysqlId));
queue.push("Transfer completed! Starting deployment on target server...");
await deployMySql(input.mysqlId).catch(() => {});
queue.push("Deployment started!");
} else {
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
}
})
.catch((error) => {
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
})
.finally(() => { done = true; });
while (!done || queue.length > 0) {
if (queue.length > 0) { yield queue.shift()!; }
else { await new Promise((r) => setTimeout(r, 50)); }
if (signal?.aborted) { return; }
}
}),
transfer: protectedProcedure
.input(apiTransferMysql)
.mutation(async ({ input, ctx }) => {
const mysql = await findMySqlById(input.mysqlId);
await checkServicePermissionAndAccess(ctx, input.mysqlId, {
service: ["delete"],
});
if (
mysql.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this MySQL",
});
}
const result = await executeTransfer(
{
serviceId: input.mysqlId,
serviceType: "mysql",
appName: mysql.appName,
sourceServerId: mysql.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Transfer failed: ${result.errors.join(", ")}`,
});
}
await db
.update(mysqlTable)
.set({ serverId: input.targetServerId })
.where(eq(mysqlTable.mysqlId, input.mysqlId));
await deployMySql(input.mysqlId).catch(() => {});
return { success: true };
}),
});

View File

@@ -677,10 +677,7 @@ export const notificationRouter = createTRPCRouter({
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? `Error testing the notification: ${error.message}`
: "Error testing the notification",
message: "Error testing the notification",
cause: error,
});
}

View File

@@ -9,7 +9,6 @@ import {
findEnvironmentById,
findPostgresById,
findProjectById,
getContainerLogs,
getMountPath,
getServiceContainerCommand,
IS_CLOUD,
@@ -22,8 +21,6 @@ import {
stopServiceRemote,
updatePostgresById,
getAccessibleServerIds,
scanServiceForTransfer,
executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -46,7 +43,6 @@ import {
apiResetPostgres,
apiSaveEnvironmentVariablesPostgres,
apiSaveExternalPortPostgres,
apiTransferPostgres,
apiUpdatePostgres,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
@@ -618,160 +614,4 @@ export const postgresRouter = createTRPCRouter({
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
readLogs: protectedProcedure
.input(
apiFindOnePostgres.extend({
tail: z.number().int().min(1).max(10000).default(100),
since: z
.string()
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
.default("all"),
search: z
.string()
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
.optional(),
}),
)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.postgresId, "read");
const postgres = await findPostgresById(input.postgresId);
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this Postgres",
});
}
return await getContainerLogs(
postgres.appName,
input.tail,
input.since,
input.search,
postgres.serverId,
);
}),
transferScan: protectedProcedure
.input(apiTransferPostgres.pick({ postgresId: true, targetServerId: true }))
.mutation(async ({ input, ctx }) => {
const postgres = await findPostgresById(input.postgresId);
await checkServicePermissionAndAccess(ctx, input.postgresId, {
service: ["delete"],
});
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this Postgres",
});
}
return await scanServiceForTransfer({
serviceId: input.postgresId,
serviceType: "postgres",
appName: postgres.appName,
sourceServerId: postgres.serverId,
targetServerId: input.targetServerId,
});
}),
transferWithLogs: protectedProcedure
.input(apiTransferPostgres)
.subscription(async function* ({ input, ctx, signal }) {
const postgres = await findPostgresById(input.postgresId);
await checkServicePermissionAndAccess(ctx, input.postgresId, {
service: ["delete"],
});
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this Postgres",
});
}
const queue: string[] = [];
let done = false;
executeTransfer(
{
serviceId: input.postgresId,
serviceType: "postgres",
appName: postgres.appName,
sourceServerId: postgres.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
(progress) => { queue.push(JSON.stringify(progress)); },
)
.then(async (result) => {
if (result.success) {
await db
.update(postgresTable)
.set({ serverId: input.targetServerId })
.where(eq(postgresTable.postgresId, input.postgresId));
queue.push("Transfer completed! Starting deployment on target server...");
await deployPostgres(input.postgresId).catch(() => {});
queue.push("Deployment started!");
} else {
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
}
})
.catch((error) => {
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
})
.finally(() => { done = true; });
while (!done || queue.length > 0) {
if (queue.length > 0) { yield queue.shift()!; }
else { await new Promise((r) => setTimeout(r, 50)); }
if (signal?.aborted) { return; }
}
}),
transfer: protectedProcedure
.input(apiTransferPostgres)
.mutation(async ({ input, ctx }) => {
const postgres = await findPostgresById(input.postgresId);
await checkServicePermissionAndAccess(ctx, input.postgresId, {
service: ["delete"],
});
if (
postgres.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this Postgres",
});
}
const result = await executeTransfer(
{
serviceId: input.postgresId,
serviceType: "postgres",
appName: postgres.appName,
sourceServerId: postgres.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Transfer failed: ${result.errors.join(", ")}`,
});
}
await db
.update(postgresTable)
.set({ serverId: input.targetServerId })
.where(eq(postgresTable.postgresId, input.postgresId));
await deployPostgres(input.postgresId).catch(() => {});
return { success: true };
}),
});

View File

@@ -8,7 +8,6 @@ import {
findEnvironmentById,
findProjectById,
findRedisById,
getContainerLogs,
getServiceContainerCommand,
IS_CLOUD,
rebuildDatabase,
@@ -20,8 +19,6 @@ import {
stopServiceRemote,
updateRedisById,
getAccessibleServerIds,
scanServiceForTransfer,
executeTransfer,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
@@ -44,7 +41,6 @@ import {
apiResetRedis,
apiSaveEnvironmentVariablesRedis,
apiSaveExternalPortRedis,
apiTransferRedis,
apiUpdateRedis,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
@@ -591,160 +587,4 @@ export const redisRouter = createTRPCRouter({
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
readLogs: protectedProcedure
.input(
apiFindOneRedis.extend({
tail: z.number().int().min(1).max(10000).default(100),
since: z
.string()
.regex(/^(all|\d+[smhd])$/, "Invalid since format")
.default("all"),
search: z
.string()
.regex(/^[a-zA-Z0-9 ._-]{0,500}$/)
.optional(),
}),
)
.query(async ({ input, ctx }) => {
await checkServiceAccess(ctx, input.redisId, "read");
const redis = await findRedisById(input.redisId);
if (
redis.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this Redis",
});
}
return await getContainerLogs(
redis.appName,
input.tail,
input.since,
input.search,
redis.serverId,
);
}),
transferScan: protectedProcedure
.input(apiTransferRedis.pick({ redisId: true, targetServerId: true }))
.mutation(async ({ input, ctx }) => {
const redis = await findRedisById(input.redisId);
await checkServicePermissionAndAccess(ctx, input.redisId, {
service: ["delete"],
});
if (
redis.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this Redis",
});
}
return await scanServiceForTransfer({
serviceId: input.redisId,
serviceType: "redis",
appName: redis.appName,
sourceServerId: redis.serverId,
targetServerId: input.targetServerId,
});
}),
transferWithLogs: protectedProcedure
.input(apiTransferRedis)
.subscription(async function* ({ input, ctx, signal }) {
const redis = await findRedisById(input.redisId);
await checkServicePermissionAndAccess(ctx, input.redisId, {
service: ["delete"],
});
if (
redis.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this Redis",
});
}
const queue: string[] = [];
let done = false;
executeTransfer(
{
serviceId: input.redisId,
serviceType: "redis",
appName: redis.appName,
sourceServerId: redis.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
(progress) => { queue.push(JSON.stringify(progress)); },
)
.then(async (result) => {
if (result.success) {
await db
.update(redisTable)
.set({ serverId: input.targetServerId })
.where(eq(redisTable.redisId, input.redisId));
queue.push("Transfer completed! Starting deployment on target server...");
await deployRedis(input.redisId).catch(() => {});
queue.push("Deployment started!");
} else {
queue.push(`Transfer failed: ${result.errors.join(", ")}`);
}
})
.catch((error) => {
queue.push(`Transfer error: ${error instanceof Error ? error.message : String(error)}`);
})
.finally(() => { done = true; });
while (!done || queue.length > 0) {
if (queue.length > 0) { yield queue.shift()!; }
else { await new Promise((r) => setTimeout(r, 50)); }
if (signal?.aborted) { return; }
}
}),
transfer: protectedProcedure
.input(apiTransferRedis)
.mutation(async ({ input, ctx }) => {
const redis = await findRedisById(input.redisId);
await checkServicePermissionAndAccess(ctx, input.redisId, {
service: ["delete"],
});
if (
redis.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this Redis",
});
}
const result = await executeTransfer(
{
serviceId: input.redisId,
serviceType: "redis",
appName: redis.appName,
sourceServerId: redis.serverId,
targetServerId: input.targetServerId,
},
input.decisions || {},
);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Transfer failed: ${result.errors.join(", ")}`,
});
}
await db
.update(redisTable)
.set({ serverId: input.targetServerId })
.where(eq(redisTable.redisId, input.redisId));
await deployRedis(input.redisId).catch(() => {});
return { success: true };
}),
});

View File

@@ -8,11 +8,7 @@ import {
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm";
import {
createTRPCRouter,
protectedProcedure,
withPermission,
} from "@/server/api/trpc";
import { createTRPCRouter, withPermission } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
apiCreateSshKey,
@@ -87,16 +83,6 @@ export const sshRouter = createTRPCRouter({
orderBy: desc(sshKeys.createdAt),
});
}),
allForApps: protectedProcedure.query(async ({ ctx }) => {
return await db.query.sshKeys.findMany({
columns: {
sshKeyId: true,
name: true,
},
where: eq(sshKeys.organizationId, ctx.session.activeOrganizationId),
orderBy: desc(sshKeys.createdAt),
});
}),
generate: withPermission("sshKeys", "read")
.input(apiGenerateSSHKey)
.mutation(async ({ input }) => {

View File

@@ -205,16 +205,11 @@ export const stripeRouter = createTRPCRouter({
mode: "subscription",
line_items: items,
...(stripeCustomerId
? {
customer: stripeCustomerId,
customer_update: { name: "auto", address: "auto" },
}
? { customer: stripeCustomerId }
: { customer_email: owner.email }),
metadata: {
adminId: owner.id,
},
billing_address_collection: "required",
tax_id_collection: { enabled: true },
allow_promotion_codes: true,
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
cancel_url: `${WEBSITE_URL}/dashboard/settings/billing`,
@@ -337,22 +332,6 @@ export const stripeRouter = createTRPCRouter({
},
),
updateInvoiceNotifications: adminProcedure
.input(z.object({ enabled: z.boolean() }))
.mutation(async ({ ctx, input }) => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This feature is only available in Dokploy Cloud",
});
}
const owner = await findUserById(ctx.user.ownerId);
await updateUser(owner.id, {
sendInvoiceNotifications: input.enabled,
});
return { ok: true };
}),
getInvoices: adminProcedure.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
const stripeCustomerId = user.stripeCustomerId;

View File

@@ -56,8 +56,6 @@ void app.prepare().then(async () => {
setupDockerStatsMonitoringSocketServer(server);
}
server.listen(PORT, HOST);
console.log(`Server Started on: http://${HOST}:${PORT}`);
if (process.env.NODE_ENV === "production" && !IS_CLOUD) {
createDefaultMiddlewares();
await initializeNetwork();
@@ -67,6 +65,9 @@ void app.prepare().then(async () => {
await initVolumeBackupsCronJobs();
await sendDokployRestartNotifications();
}
server.listen(PORT, HOST);
console.log(`Server Started on: http://${HOST}:${PORT}`);
await initEnterpriseBackupCronJobs();
if (!IS_CLOUD) {

View File

@@ -1,113 +0,0 @@
import InvoiceNotificationEmail from "@dokploy/server/emails/emails/invoice-notification";
import PaymentFailedEmail from "@dokploy/server/emails/emails/payment-failed";
import { sendEmail } from "@dokploy/server/verification/send-verification-email";
import { renderAsync } from "@react-email/components";
import { format } from "date-fns";
import type Stripe from "stripe";
function formatAmount(amountInCents: number, currency: string): string {
const amount = amountInCents / 100;
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
});
return formatter.format(amount);
}
const downloadPdf = async (url: string): Promise<Buffer | null> => {
try {
const response = await fetch(url);
if (!response.ok) return null;
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch {
return null;
}
};
export const sendInvoiceEmail = async (
invoice: Stripe.Invoice,
admin: { email: string; firstName: string },
) => {
if (!invoice.hosted_invoice_url) return;
try {
const amountFormatted = formatAmount(invoice.amount_paid, invoice.currency);
const htmlContent = await renderAsync(
InvoiceNotificationEmail({
userName: admin.firstName || "User",
invoiceNumber: invoice.number || invoice.id,
amountPaid: amountFormatted,
currency: invoice.currency,
date: format(new Date(invoice.created * 1000), "MMM dd, yyyy"),
hostedInvoiceUrl: invoice.hosted_invoice_url,
}),
);
const attachments: { filename: string; content: Buffer }[] = [];
if (invoice.invoice_pdf) {
const pdfBuffer = await downloadPdf(invoice.invoice_pdf);
if (pdfBuffer) {
attachments.push({
filename: `dokploy-invoice-${invoice.number || invoice.id}.pdf`,
content: pdfBuffer,
});
}
}
await sendEmail({
email: admin.email,
subject: `Dokploy Invoice ${invoice.number || ""} - ${amountFormatted}`,
text: htmlContent,
attachments,
});
console.log(
`Invoice email sent to ${admin.email} for invoice ${invoice.number}`,
);
} catch (error) {
console.error(
`Failed to send invoice email to ${admin.email}:`,
error instanceof Error ? error.message : error,
);
}
};
export const sendPaymentFailedEmail = async (
invoice: Stripe.Invoice,
admin: { email: string; firstName: string },
) => {
if (!invoice.hosted_invoice_url) return;
try {
const amountFormatted = formatAmount(invoice.amount_due, invoice.currency);
const htmlContent = await renderAsync(
PaymentFailedEmail({
userName: admin.firstName || "User",
invoiceNumber: invoice.number || invoice.id,
amountDue: amountFormatted,
currency: invoice.currency,
date: format(new Date(invoice.created * 1000), "MMM dd, yyyy"),
hostedInvoiceUrl: invoice.hosted_invoice_url,
}),
);
await sendEmail({
email: admin.email,
subject: `Action required: Dokploy payment failed - ${amountFormatted}`,
text: htmlContent,
});
console.log(
`Payment failed email sent to ${admin.email} for invoice ${invoice.number}`,
);
} catch (error) {
console.error(
`Failed to send payment failed email to ${admin.email}:`,
error instanceof Error ? error.message : error,
);
}
};

View File

@@ -33,7 +33,7 @@ app.use(async (c, next) => {
app.post("/create-backup", zValidator("json", jobQueueSchema), async (c) => {
const data = c.req.valid("json");
await scheduleJob(data);
scheduleJob(data);
logger.info({ data }, `[${data.type}] created successfully`);
return c.json({ message: `[${data.type}] created successfully` });
});
@@ -70,7 +70,7 @@ app.post("/update-backup", zValidator("json", jobQueueSchema), async (c) => {
}
logger.info({ result }, "Job removed");
}
await scheduleJob(data);
scheduleJob(data);
logger.info("Backup updated successfully");
return c.json({ message: "Backup updated successfully" });
@@ -103,11 +103,8 @@ process.on("uncaughtException", (err) => {
logger.error(err, "Uncaught exception");
});
process.on("unhandledRejection", (reason, _promise) => {
logger.error(
reason instanceof Error ? reason : { reason: String(reason) },
"Unhandled Rejection at: Promise",
);
process.on("unhandledRejection", (reason, promise) => {
logger.error({ promise, reason }, "Unhandled Rejection at: Promise");
});
const port = Number.parseInt(process.env.PORT || "3000");

View File

@@ -21,28 +21,28 @@ export const cleanQueue = async () => {
}
};
export const scheduleJob = async (job: QueueJob) => {
export const scheduleJob = (job: QueueJob) => {
if (job.type === "backup") {
await jobQueue.add(job.backupId, job, {
jobQueue.add(job.backupId, job, {
repeat: {
pattern: job.cronSchedule,
},
});
} else if (job.type === "server") {
await jobQueue.add(`${job.serverId}-cleanup`, job, {
jobQueue.add(`${job.serverId}-cleanup`, job, {
repeat: {
pattern: job.cronSchedule,
},
});
} else if (job.type === "schedule") {
await jobQueue.add(job.scheduleId, job, {
jobQueue.add(job.scheduleId, job, {
repeat: {
pattern: job.cronSchedule,
tz: job.timezone || "UTC",
},
});
} else if (job.type === "volume-backup") {
await jobQueue.add(job.volumeBackupId, job, {
jobQueue.add(job.volumeBackupId, job, {
repeat: {
pattern: job.cronSchedule,
},

View File

@@ -135,18 +135,11 @@ export const initializeJobs = async () => {
for (const server of servers) {
const { serverId } = server;
try {
await scheduleJob({
serverId,
type: "server",
cronSchedule: CLEANUP_CRON_JOB,
});
} catch (error) {
logger.error(
error,
`Failed to schedule cleanup job for server ${serverId}`,
);
}
scheduleJob({
serverId,
type: "server",
cronSchedule: CLEANUP_CRON_JOB,
});
}
logger.info({ Quantity: servers.length }, "Active Servers Initialized");
@@ -164,15 +157,11 @@ export const initializeJobs = async () => {
});
for (const backup of backupsResult) {
try {
await scheduleJob({
backupId: backup.backupId,
type: "backup",
cronSchedule: backup.schedule,
});
} catch (error) {
logger.error(error, `Failed to schedule backup ${backup.backupId}`);
}
scheduleJob({
backupId: backup.backupId,
type: "backup",
cronSchedule: backup.schedule,
});
}
logger.info({ Quantity: backupsResult.length }, "Backups Initialized");
@@ -208,15 +197,11 @@ export const initializeJobs = async () => {
);
for (const schedule of filteredSchedulesBasedOnServerStatus) {
try {
await scheduleJob({
scheduleId: schedule.scheduleId,
type: "schedule",
cronSchedule: schedule.cronExpression,
});
} catch (error) {
logger.error(error, `Failed to schedule ${schedule.scheduleId}`);
}
scheduleJob({
scheduleId: schedule.scheduleId,
type: "schedule",
cronSchedule: schedule.cronExpression,
});
}
logger.info(
{ Quantity: filteredSchedulesBasedOnServerStatus.length },
@@ -251,18 +236,11 @@ export const initializeJobs = async () => {
);
for (const volumeBackup of filteredVolumeBackupsBasedOnServerStatus) {
try {
await scheduleJob({
volumeBackupId: volumeBackup.volumeBackupId,
type: "volume-backup",
cronSchedule: volumeBackup.cronExpression,
});
} catch (error) {
logger.error(
error,
`Failed to schedule volume backup ${volumeBackup.volumeBackupId}`,
);
}
scheduleJob({
volumeBackupId: volumeBackup.volumeBackupId,
type: "volume-backup",
cronSchedule: volumeBackup.cronExpression,
});
}
logger.info(

View File

@@ -534,9 +534,3 @@ export const apiUpdateApplication = createSchema
applicationId: z.string().min(1),
})
.omit({ serverId: true });
export const apiTransferApplication = z.object({
applicationId: z.string().min(1),
targetServerId: z.string().min(1),
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
});

View File

@@ -225,13 +225,6 @@ export const apiUpdateCompose = createSchema
})
.omit({ serverId: true });
export const apiSaveEnvironmentVariablesCompose = createSchema
.pick({
composeId: true,
env: true,
})
.required();
export const apiRandomizeCompose = createSchema
.pick({
composeId: true,
@@ -240,9 +233,3 @@ export const apiRandomizeCompose = createSchema
suffix: z.string().optional(),
composeId: z.string().min(1),
});
export const apiTransferCompose = z.object({
composeId: z.string().min(1),
targetServerId: z.string().min(1),
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
});

View File

@@ -213,9 +213,3 @@ export const apiRebuildMariadb = createSchema
mariadbId: true,
})
.required();
export const apiTransferMariadb = z.object({
mariadbId: z.string().min(1),
targetServerId: z.string().min(1),
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
});

View File

@@ -210,9 +210,3 @@ export const apiRebuildMongo = createSchema
mongoId: true,
})
.required();
export const apiTransferMongo = z.object({
mongoId: z.string().min(1),
targetServerId: z.string().min(1),
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
});

View File

@@ -210,9 +210,3 @@ export const apiRebuildMysql = createSchema
mysqlId: true,
})
.required();
export const apiTransferMysql = z.object({
mysqlId: z.string().min(1),
targetServerId: z.string().min(1),
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
});

View File

@@ -204,9 +204,3 @@ export const apiRebuildPostgres = createSchema
postgresId: true,
})
.required();
export const apiTransferPostgres = z.object({
postgresId: z.string().min(1),
targetServerId: z.string().min(1),
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
});

View File

@@ -187,9 +187,3 @@ export const apiRebuildRedis = createSchema
redisId: true,
})
.required();
export const apiTransferRedis = z.object({
redisId: z.string().min(1),
targetServerId: z.string().min(1),
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
});

View File

@@ -65,9 +65,6 @@ export const user = pgTable("user", {
stripeCustomerId: text("stripeCustomerId"),
stripeSubscriptionId: text("stripeSubscriptionId"),
serversQuantity: integer("serversQuantity").notNull().default(0),
sendInvoiceNotifications: boolean("sendInvoiceNotifications")
.notNull()
.default(false),
isEnterpriseCloud: boolean("isEnterpriseCloud").notNull().default(false),
trustedOrigins: text("trustedOrigins").array(),
bookmarkedTemplates: text("bookmarkedTemplates")

View File

@@ -1,171 +0,0 @@
import {
Body,
Button,
Column,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Tailwind,
Text,
} from "@react-email/components";
export type TemplateProps = {
userName: string;
invoiceNumber: string;
amountPaid: string;
currency: string;
date: string;
hostedInvoiceUrl: string;
};
export const InvoiceNotificationEmail = ({
userName = "User",
invoiceNumber = "INV-0001",
amountPaid = "$4.50",
currency = "usd",
date = "2024-01-01",
hostedInvoiceUrl = "https://invoice.stripe.com/example",
}: TemplateProps) => {
const previewText = `Your Dokploy invoice ${invoiceNumber} for ${amountPaid} is ready`;
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]">
Invoice Payment Confirmed
</Heading>
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
Hello {userName}, thank you for your payment. Here's a summary
of your invoice.
</Text>
{/* Invoice Details Card */}
<Section className="border border-solid border-[#e4e4e7] rounded-lg overflow-hidden mb-[24px]">
<Row className="bg-[#fafafa]">
<Column className="px-[20px] py-[14px] w-[50%]">
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
Invoice No.
</Text>
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
{invoiceNumber}
</Text>
</Column>
<Column className="px-[20px] py-[14px] w-[50%]">
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
Date
</Text>
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
{date}
</Text>
</Column>
</Row>
<Hr className="border-[#e4e4e7] m-0" />
<Row>
<Column className="px-[20px] py-[14px]">
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
Amount Paid
</Text>
<Text className="text-[#09090b] text-[20px] font-bold m-0 mt-[4px]">
{amountPaid}{" "}
<span className="text-[#71717a] text-[12px] font-normal uppercase">
{currency}
</span>
</Text>
</Column>
</Row>
</Section>
{/* Status Badge */}
<Section className="mb-[24px]">
<Row>
<Column>
<div
className="inline-block rounded-full bg-[#dcfce7] px-[12px] py-[6px]"
style={{ display: "inline-block" }}
>
<Text className="text-[#15803d] text-[12px] font-semibold m-0">
Payment Successful
</Text>
</div>
</Column>
</Row>
</Section>
{/* CTA Button */}
<Section className="text-center mb-[24px]">
<Button
href={hostedInvoiceUrl}
className="bg-[#09090b] rounded-lg text-white text-[14px] font-semibold no-underline text-center px-[24px] py-[12px]"
>
View Invoice Online
</Button>
</Section>
<Text className="text-[#a1a1aa] text-[13px] leading-[20px] m-0 text-center">
A PDF copy of this invoice is attached to this email for your
records.
</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 have any questions about your billing, please contact
our{" "}
<Link
href="https://discord.gg/2tBnJ3jDJc"
className="text-[#71717a] underline"
>
support team
</Link>
.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default InvoiceNotificationEmail;

View File

@@ -1,175 +0,0 @@
import {
Body,
Button,
Column,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Tailwind,
Text,
} from "@react-email/components";
export type TemplateProps = {
userName: string;
invoiceNumber: string;
amountDue: string;
currency: string;
date: string;
hostedInvoiceUrl: string;
};
export const PaymentFailedEmail = ({
userName = "User",
invoiceNumber = "INV-0001",
amountDue = "$4.50",
currency = "usd",
date = "2024-01-01",
hostedInvoiceUrl = "https://invoice.stripe.com/example",
}: TemplateProps) => {
const previewText = `Action required: Your Dokploy payment for ${amountDue} failed`;
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]">
Payment Failed
</Heading>
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
Hello {userName}, we were unable to process your payment. Please
update your payment method to avoid service interruption.
</Text>
{/* Invoice Details Card */}
<Section className="border border-solid border-[#e4e4e7] rounded-lg overflow-hidden mb-[24px]">
<Row className="bg-[#fafafa]">
<Column className="px-[20px] py-[14px] w-[50%]">
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
Invoice No.
</Text>
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
{invoiceNumber}
</Text>
</Column>
<Column className="px-[20px] py-[14px] w-[50%]">
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
Date
</Text>
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
{date}
</Text>
</Column>
</Row>
<Hr className="border-[#e4e4e7] m-0" />
<Row>
<Column className="px-[20px] py-[14px]">
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
Amount Due
</Text>
<Text className="text-[#09090b] text-[20px] font-bold m-0 mt-[4px]">
{amountDue}{" "}
<span className="text-[#71717a] text-[12px] font-normal uppercase">
{currency}
</span>
</Text>
</Column>
</Row>
</Section>
{/* Status Badge */}
<Section className="mb-[24px]">
<Row>
<Column>
<div
className="inline-block rounded-full bg-[#fee2e2] px-[12px] py-[6px]"
style={{ display: "inline-block" }}
>
<Text className="text-[#dc2626] text-[12px] font-semibold m-0">
Payment Failed
</Text>
</div>
</Column>
</Row>
</Section>
{/* Warning */}
<Section className="bg-[#fefce8] border border-solid border-[#fef08a] rounded-lg px-[20px] py-[16px] mb-[24px]">
<Text className="text-[#854d0e] text-[13px] leading-[20px] m-0">
If the payment issue is not resolved, your servers will be
deactivated. Please update your payment method as soon as
possible.
</Text>
</Section>
{/* CTA Button */}
<Section className="text-center mb-[24px]">
<Button
href={hostedInvoiceUrl}
className="bg-[#dc2626] rounded-lg text-white text-[14px] font-semibold no-underline text-center px-[24px] py-[12px]"
>
Update Payment Method
</Button>
</Section>
</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 have any questions about your billing, please contact
our{" "}
<Link
href="https://discord.gg/2tBnJ3jDJc"
className="text-[#71717a] underline"
>
support team
</Link>
.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default PaymentFailedEmail;

View File

@@ -47,7 +47,6 @@ export * from "./services/server";
export * from "./services/settings";
export * from "./services/ssh-key";
export * from "./services/user";
export * from "./services/transfer";
export * from "./services/volume-backups";
export * from "./services/web-server-settings";
export * from "./setup/config-paths";
@@ -132,7 +131,6 @@ export * from "./utils/traefik/redirect";
export * from "./utils/traefik/security";
export * from "./utils/traefik/types";
export * from "./utils/traefik/web-server";
export * from "./utils/transfer/index";
export * from "./utils/volume-backups/index";
export * from "./utils/watch-paths/should-deploy";
export * from "./wss/utils";

View File

@@ -196,7 +196,7 @@ const { handler, api } = betterAuth({
where: eq(schema.member.role, "owner"),
});
if (!IS_CLOUD && !isAdminPresent) {
if (!IS_CLOUD) {
await updateWebServerSettings({
serverIp: await getPublicIpWithFallback(),
});

View File

@@ -108,45 +108,22 @@ export const suggestVariants = async ({
ip = "127.0.0.1";
}
const fullSchema = z.object({
const suggestionsSchema = z.object({
suggestions: z.array(
z.object({
id: z.string(),
name: z.string(),
shortDescription: z.string(),
description: z.string(),
dockerCompose: z.string(),
envVariables: z.array(
z.object({
name: z.string(),
value: z.string(),
}),
),
domains: z.array(
z.object({
host: z.string(),
port: z.number(),
serviceName: z.string(),
}),
),
configFiles: z
.array(
z.object({
content: z.string(),
filePath: z.string(),
}),
)
.optional(),
}),
),
});
const result = await generateText({
const suggestionsResult = await generateText({
model,
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
output: Output.object({ schema: fullSchema }),
output: Output.object({ schema: suggestionsSchema }),
prompt: `
Act as advanced DevOps engineer. Analyze the user's request and generate up to 3 deployment suggestions, each with a complete docker compose configuration.
Act as advanced DevOps engineer and analyze the user's request to determine the appropriate suggestions (up to 3 items).
CRITICAL - Read the user's request carefully and follow the appropriate strategy:
@@ -162,94 +139,163 @@ export const suggestVariants = async ({
- Example: For "personal blog" → "WordPress", "Ghost", "Hugo with Nginx"
- The name should be the actual project name
Return your response as a JSON object with this structure:
Return your response as a JSON object with the following structure:
{
"suggestions": [
{
"id": "project-or-variant-slug",
"name": "Project Name or Variant Name",
"shortDescription": "Brief one-line description",
"description": "Detailed description of the project/variant",
"dockerCompose": "yaml string here",
"envVariables": [{"name": "VAR_NAME", "value": "example_value"}],
"domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}],
"configFiles": [{"content": "file content", "filePath": "path/to/file"}]
"description": "Detailed description"
}
]
}
Suggestion Rules:
Important rules for the response:
1. Use slug format for the id field (lowercase, hyphenated)
2. The description field should ONLY contain plain text — no code snippets or installation instructions
3. The shortDescription should be a single-line summary focusing on key technologies or differentiators
4. All suggestions should be installable in docker and have docker compose support
5. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches
2. Determine which strategy to use based on whether the user specified a particular application or described a general need
3. For Strategy A (specific app): The name must include the app name and describe the variant configuration
4. For Strategy B (general need): The name should be the actual project/tool name that fulfills the need
5. The description field should ONLY contain a plain text description of the project or variant, its features, and use cases
6. Do NOT include any code snippets, configuration examples, or installation instructions in the description
7. The shortDescription should be a single-line summary focusing on key technologies or differentiators
8. All suggestions should be installable in docker and have docker compose support
9. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches
Docker Compose Rules:
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml
2. Use complex values for passwords/secrets variables
3. Don't set container_name field in services
4. Don't set version field in the docker compose
5. Don't set ports like 'ports: 3000:3000', use 'ports: "3000"' instead
6. If a service depends on a database or other service, INCLUDE that service in the docker-compose
7. Make sure all required services are defined in the docker-compose
User wants to create a new project with the following details:
Docker Image Rules (CRITICAL):
1. ALWAYS use 'image:' field, NEVER use 'build:' field
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
6. Examples of correct image usage:
- image: sendingtk/chatwoot:develop
- image: postgres:16-alpine
- image: redis:7-alpine
7. Examples of INCORRECT usage (DO NOT USE):
- build: .
- build: ./app
- build:
context: .
dockerfile: Dockerfile
Volume Mounting and Configuration Rules:
1. DO NOT create configuration files unless the service CANNOT work without them
2. Most services can work with just environment variables - USE THEM FIRST
3. If and ONLY IF a config file is absolutely required:
- Keep it minimal with only critical settings
- Use "../files/" prefix for all mounts
- Format: "../files/folder:/container/path"
4. DO NOT add configuration files for default configs, env-configurable settings, or proxy/routing configs
Environment Variables Rules:
1. For the envVariables array, provide ACTUAL example values, not placeholders
2. Use realistic example values (e.g., "admin@example.com" for emails, "mypassword123" for passwords)
3. DO NOT use \${VARIABLE_NAME-default} syntax in the envVariables values
4. ONLY include environment variables that are actually used in the docker-compose
5. Every environment variable referenced in the docker-compose MUST have a corresponding entry in envVariables
Domain Rules - For each service that needs to be exposed to the internet:
1. Define a domain with:
- host: {service-name}-{random-3-chars-hex}-${ip ? ip.replaceAll(".", "-") : ""}.traefik.me
- port: the internal port the service runs on
- serviceName: the name of the service in the docker-compose
2. Make sure the service is properly configured to work with the specified port
User's request: ${input}
${input}
`,
});
const object = suggestionsResult.output as SuggestionsOutput | undefined;
const output = result.output as
| { suggestions: (SuggestionItem & DockerOutput)[] }
| undefined;
if (!output?.suggestions?.length) {
throw new TRPCError({
code: "NOT_FOUND",
message: "No suggestions found",
if (object?.suggestions?.length) {
const dockerSchema = z.object({
dockerCompose: z.string(),
envVariables: z.array(
z.object({
name: z.string(),
value: z.string(),
}),
),
domains: z.array(
z.object({
host: z.string(),
port: z.number(),
serviceName: z.string(),
}),
),
configFiles: z
.array(
z.object({
content: z.string(),
filePath: z.string(),
}),
)
.optional(),
});
const result = [];
for (const suggestion of object.suggestions) {
try {
const dockerResult = await generateText({
model,
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
output: Output.object({ schema: dockerSchema }),
prompt: `
Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project.
Return your response as a JSON object with this structure:
{
"dockerCompose": "yaml string here",
"envVariables": [{"name": "VAR_NAME", "value": "example_value"}],
"domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}],
"configFiles": [{"content": "file content", "filePath": "path/to/file"}]
}
Note: configFiles is optional - only include it if configuration files are absolutely required.
Follow these rules:
Docker Compose Rules:
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml
2. Use complex values for passwords/secrets variables
3. Don't set container_name field in services
4. Don't set version field in the docker compose
5. Don't set ports like 'ports: 3000:3000', use 'ports: "3000"' instead
6. If a service depends on a database or other service, INCLUDE that service in the docker-compose
7. Make sure all required services are defined in the docker-compose
Docker Image Rules (CRITICAL):
1. ALWAYS use 'image:' field, NEVER use 'build:' field
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
6. Examples of correct image usage:
- image: sendingtk/chatwoot:develop
- image: postgres:16-alpine
- image: redis:7-alpine
- image: chatwoot/chatwoot:latest
7. Examples of INCORRECT usage (DO NOT USE):
- build: .
- build: ./app
- build:
context: .
dockerfile: Dockerfile
Volume Mounting and Configuration Rules:
1. DO NOT create configuration files unless the service CANNOT work without them
2. Most services can work with just environment variables - USE THEM FIRST
3. Ask yourself: "Can this be configured with an environment variable instead?"
4. If and ONLY IF a config file is absolutely required:
- Keep it minimal with only critical settings
- Use "../files/" prefix for all mounts
- Format: "../files/folder:/container/path"
5. DO NOT add configuration files for:
- Default configurations that work out of the box
- Settings that can be handled by environment variables
- Proxy or routing configurations (these are handled elsewhere)
Environment Variables Rules:
1. For the envVariables array, provide ACTUAL example values, not placeholders
2. Use realistic example values (e.g., "admin@example.com" for emails, "mypassword123" for passwords)
3. DO NOT use \${VARIABLE_NAME-default} syntax in the envVariables values
4. ONLY include environment variables that are actually used in the docker-compose
5. Every environment variable referenced in the docker-compose MUST have a corresponding entry in envVariables
6. Do not include environment variables for services that don't exist in the docker-compose
For each service that needs to be exposed to the internet:
1. Define a domain configuration with:
- host: the domain name for the service in format: {service-name}-{random-3-chars-hex}-${ip ? ip.replaceAll(".", "-") : ""}.traefik.me
- port: the internal port the service runs on
- serviceName: the name of the service in the docker-compose
2. Make sure the service is properly configured to work with the specified port
User's original request: ${input}
Project details:
${suggestion?.description}
`,
});
const docker = dockerResult.output as DockerOutput | undefined;
if (docker?.dockerCompose) {
result.push({
...suggestion,
...docker,
});
}
} catch (error) {
console.error("Error in docker compose generation:", error);
}
}
return result;
}
return output.suggestions.filter((s) => s.dockerCompose);
throw new TRPCError({
code: "NOT_FOUND",
message: "No suggestions found",
});
} catch (error) {
console.error("Error in suggestVariants:", error);
throw error;

View File

@@ -251,22 +251,15 @@ export const deployCompose = async ({
} else {
await execAsync(commandWithLog);
}
command = "set -e;";
if (compose.sourceType !== "raw") {
command = "set -e;";
command += await generateApplyPatchesCommand({
id: compose.composeId,
type: "compose",
serverId: compose.serverId,
});
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, commandWithLog);
} else {
await execAsync(commandWithLog);
}
}
command = "set -e;";
command += await getBuildComposeCommand(entity);
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (compose.serverId) {
@@ -364,23 +357,6 @@ export const rebuildCompose = async ({
} else {
await execAsync(commandWithLog);
}
if (compose.sourceType !== "raw") {
command = "set -e;";
command += await generateApplyPatchesCommand({
id: compose.composeId,
type: "compose",
serverId: compose.serverId,
});
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, commandWithLog);
} else {
await execAsync(commandWithLog);
}
}
command = "set -e;";
command += await getBuildComposeCommand(compose);
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (compose.serverId) {
@@ -440,16 +416,17 @@ export const removeCompose = async (
}
} else {
const command = `
docker network disconnect ${compose.appName} dokploy-traefik;
env -i PATH="$PATH" docker compose -p ${compose.appName} down ${
docker network disconnect ${compose.appName} dokploy-traefik;
cd ${projectPath} && env -i PATH="$PATH" docker compose -p ${compose.appName} down ${
deleteVolumes ? "--volumes" : ""
};
rm -rf ${projectPath}`;
} && rm -rf ${projectPath}`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, command);
} else {
await execAsync(command);
await execAsync(command, {
cwd: projectPath,
});
}
}
} catch (error) {

View File

@@ -354,121 +354,21 @@ export const getContainersByAppLabel = async (
return [];
};
export const getContainerLogs = async (
appNameOrId: string,
tail = 100,
since = "all",
search?: string,
serverId?: string | null,
useContainerIdDirectly = false,
): Promise<string> => {
const exec = (cmd: string) =>
serverId ? execAsyncRemote(serverId, cmd) : execAsync(cmd);
let target = appNameOrId;
let isService = false;
if (!useContainerIdDirectly) {
// Find the real container ID by appName filter
const findResult = await exec(
`docker ps -q --filter "name=^${appNameOrId}" | head -1`,
);
const containerId = findResult.stdout.trim();
if (!containerId) {
// Fallback: try as a swarm service
const svcResult = await exec(
`docker service ls -q --filter "name=${appNameOrId}" | head -1`,
);
const serviceId = svcResult.stdout.trim();
if (!serviceId) {
throw new Error(`No container or service found for: ${appNameOrId}`);
}
isService = true;
} else {
target = containerId;
}
}
const sinceFlag = since === "all" ? "" : `--since ${since}`;
const baseCommand = isService
? `docker service logs --timestamps --raw --tail ${tail} ${sinceFlag} ${target}`
: `docker container logs --timestamps --tail ${tail} ${sinceFlag} ${target}`;
const escapedSearch = search?.replace(/'/g, "'\\''") ?? "";
const command = search
? `${baseCommand} 2>&1 | grep -iF '${escapedSearch}'`
: `${baseCommand} 2>&1`;
export const containerRestart = async (containerId: string) => {
try {
const result = await exec(command);
return result.stdout;
} catch (error: unknown) {
if (
error &&
typeof error === "object" &&
"stdout" in error &&
typeof (error as { stdout: string }).stdout === "string" &&
(error as { stdout: string }).stdout.length > 0
) {
return (error as { stdout: string }).stdout;
const { stdout, stderr } = await execAsync(
`docker container restart ${containerId}`,
);
if (stderr) {
console.error(`Error: ${stderr}`);
return;
}
throw error;
}
};
export const containerRestart = async (
containerId: string,
serverId?: string,
) => {
const command = `docker container restart ${containerId}`;
const { stderr } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
const config = JSON.parse(stdout);
if (stderr) {
console.error(`Error: ${stderr}`);
throw new Error(stderr);
}
};
export const containerStart = async (
containerId: string,
serverId?: string,
) => {
const command = `docker container start ${containerId}`;
const { stderr } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
if (stderr) {
console.error(`Error: ${stderr}`);
throw new Error(stderr);
}
};
export const containerStop = async (containerId: string, serverId?: string) => {
const command = `docker container stop ${containerId}`;
const { stderr } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
if (stderr) {
console.error(`Error: ${stderr}`);
throw new Error(stderr);
}
};
export const containerKill = async (containerId: string, serverId?: string) => {
const command = `docker container kill ${containerId}`;
const { stderr } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
if (stderr) {
console.error(`Error: ${stderr}`);
throw new Error(stderr);
}
return config;
} catch {}
};
export const containerRemove = async (

View File

@@ -1,456 +0,0 @@
import { paths } from "@dokploy/server/constants";
import path from "node:path";
import { findMountsByApplicationId } from "./mount";
import {
compareFileLists,
getDirectorySize,
getVolumeSize,
listComposeVolumes,
listVolumesByPrefix,
scanDirectory,
scanDockerVolume,
scanMount,
} from "../utils/transfer/scanner";
import { runPreflightChecks } from "../utils/transfer/preflight";
import {
syncDirectory,
syncDockerVolume,
syncMount,
syncTraefikConfig,
} from "../utils/transfer/sync";
import type {
ConflictDecision,
MountTransferConfig,
ServiceType,
TransferOptions,
TransferProgress,
TransferResult,
TransferScanResult,
} from "../utils/transfer/types";
const getServiceBasePath = (
serviceType: ServiceType,
appName: string,
isRemote: boolean,
): string => {
if (serviceType === "compose") {
const { COMPOSE_PATH } = paths(isRemote);
return path.join(COMPOSE_PATH, appName);
}
const { APPLICATIONS_PATH } = paths(isRemote);
return path.join(APPLICATIONS_PATH, appName);
};
const hasServiceDirectory = (serviceType: ServiceType): boolean => {
return serviceType === "application" || serviceType === "compose";
};
const getAutoDataVolumeName = (
serviceType: ServiceType,
appName: string,
): string | null => {
const dbTypes: ServiceType[] = [
"postgres",
"mysql",
"mariadb",
"mongo",
"redis",
];
if (dbTypes.includes(serviceType)) {
return `${appName}-data`;
}
return null;
};
/**
* Discover all Docker volumes for a service.
* For compose: uses Docker labels + prefix matching.
* For databases: uses the auto {appName}-data convention.
* For applications: uses user-defined mounts only.
*/
const discoverServiceVolumes = async (
serverId: string | null,
serviceType: ServiceType,
appName: string,
): Promise<string[]> => {
const volumes: Set<string> = new Set();
if (serviceType === "compose") {
// Get volumes by compose project label
const labelVolumes = await listComposeVolumes(serverId, appName);
for (const v of labelVolumes) {
volumes.add(v);
}
// Also try prefix matching (compose uses {projectName}_{volumeName} pattern)
const prefixVolumes = await listVolumesByPrefix(serverId, `${appName}_`);
for (const v of prefixVolumes) {
volumes.add(v);
}
}
// Auto data volume for databases
const autoVolume = getAutoDataVolumeName(serviceType, appName);
if (autoVolume) {
volumes.add(autoVolume);
}
return Array.from(volumes);
};
export const scanServiceForTransfer = async (
opts: TransferOptions,
): Promise<TransferScanResult> => {
const { serviceType, appName, sourceServerId, targetServerId } = opts;
const result: TransferScanResult = {
serviceDirectory: { files: [], totalSize: 0 },
traefikConfig: { exists: false, hasConflict: false },
mounts: [],
totalTransferSize: 0,
totalFiles: 0,
conflicts: [],
};
// 1. Scan service directory (application/compose only)
if (hasServiceDirectory(serviceType)) {
const sourcePath = getServiceBasePath(
serviceType,
appName,
!!sourceServerId,
);
const targetPath = getServiceBasePath(serviceType, appName, true);
const sourceFiles = await scanDirectory(sourceServerId, sourcePath);
const targetFiles = await scanDirectory(targetServerId, targetPath);
const dirSize = await getDirectorySize(sourceServerId, sourcePath);
const fileConflicts = compareFileLists(sourceFiles, targetFiles);
result.serviceDirectory = {
files: fileConflicts,
totalSize: dirSize || sourceFiles.reduce((sum, f) => sum + f.size, 0),
};
}
// 2. Check Traefik config
if (serviceType === "application" || serviceType === "compose") {
const { DYNAMIC_TRAEFIK_PATH } = paths(!!sourceServerId);
const configFile = `${appName}.yml`;
const sourceConfigFiles = await scanDirectory(
sourceServerId,
DYNAMIC_TRAEFIK_PATH,
);
const hasSourceConfig = sourceConfigFiles.some(
(f) => f.path === configFile,
);
if (hasSourceConfig) {
result.traefikConfig.exists = true;
const { DYNAMIC_TRAEFIK_PATH: targetTraefikPath } = paths(true);
const targetConfigFiles = await scanDirectory(
targetServerId,
targetTraefikPath,
);
result.traefikConfig.hasConflict = targetConfigFiles.some(
(f) => f.path === configFile,
);
}
}
// 3. Discover and scan ALL Docker volumes for the service
const discoveredVolumes = await discoverServiceVolumes(
sourceServerId,
serviceType,
appName,
);
for (const volumeName of discoveredVolumes) {
const sourceFiles = await scanDockerVolume(sourceServerId, volumeName);
const targetFiles = await scanDockerVolume(targetServerId, volumeName);
const volSize = await getVolumeSize(sourceServerId, volumeName);
const fileConflicts = compareFileLists(sourceFiles, targetFiles);
result.mounts.push({
mount: {
mountId: `docker-${volumeName}`,
type: "volume",
volumeName,
mountPath: "/data",
},
files: fileConflicts,
totalSize: volSize || sourceFiles.reduce((sum, f) => sum + f.size, 0),
});
}
// 4. Scan user-defined mounts from Dokploy DB
const serviceTypeForMount = serviceType as
| "application"
| "postgres"
| "mysql"
| "mariadb"
| "mongo"
| "redis"
| "compose";
const userMounts = await findMountsByApplicationId(
opts.serviceId,
serviceTypeForMount,
);
for (const mount of userMounts) {
if (mount.type === "file") continue;
// Skip if already discovered as Docker volume
if (
mount.type === "volume" &&
mount.volumeName &&
discoveredVolumes.includes(mount.volumeName)
) {
continue;
}
const mountConfig: MountTransferConfig = {
mountId: mount.mountId,
type: mount.type,
hostPath: mount.hostPath,
volumeName: mount.volumeName,
mountPath: mount.mountPath,
content: mount.content,
filePath: mount.filePath,
};
const sourceFiles = await scanMount(sourceServerId, mountConfig);
const targetFiles = await scanMount(targetServerId, mountConfig);
let mountSize = 0;
if (mount.type === "volume" && mount.volumeName) {
mountSize = await getVolumeSize(sourceServerId, mount.volumeName);
} else if (mount.type === "bind" && mount.hostPath) {
mountSize = await getDirectorySize(sourceServerId, mount.hostPath);
}
const fileConflicts = compareFileLists(sourceFiles, targetFiles);
result.mounts.push({
mount: mountConfig,
files: fileConflicts,
totalSize: mountSize || sourceFiles.reduce((sum, f) => sum + f.size, 0),
});
}
// Calculate totals
result.totalTransferSize =
result.serviceDirectory.totalSize +
result.mounts.reduce((sum, m) => sum + m.totalSize, 0);
result.totalFiles =
result.serviceDirectory.files.length +
result.mounts.reduce((sum, m) => sum + m.files.length, 0);
result.conflicts = [
...result.serviceDirectory.files,
...result.mounts.flatMap((m) => m.files),
].filter((f) => f.status !== "match" && f.status !== "missing_target");
return result;
};
export const executeTransfer = async (
opts: TransferOptions,
decisions: Record<string, ConflictDecision>,
onProgress?: (progress: TransferProgress) => void,
): Promise<TransferResult> => {
const { serviceType, appName, sourceServerId, targetServerId } = opts;
const errors: string[] = [];
const processedFiles = 0;
const transferredBytes = 0;
const reportProgress = (
phase: TransferProgress["phase"],
message?: string,
currentFile?: string,
) => {
onProgress?.({
phase,
currentFile,
processedFiles,
totalFiles: 0,
transferredBytes,
totalBytes: 0,
percentage: 0,
message,
});
};
try {
// Phase 1: Preflight
reportProgress("preparing", "Running preflight checks...");
// Discover all volumes
const discoveredVolumes = await discoverServiceVolumes(
sourceServerId,
serviceType,
appName,
);
// User-defined mounts
const mountConfigs: MountTransferConfig[] = [];
const serviceTypeForMount = serviceType as
| "application"
| "postgres"
| "mysql"
| "mariadb"
| "mongo"
| "redis"
| "compose";
const userMounts = await findMountsByApplicationId(
opts.serviceId,
serviceTypeForMount,
);
for (const mount of userMounts) {
if (mount.type === "file") continue;
if (
mount.type === "volume" &&
mount.volumeName &&
discoveredVolumes.includes(mount.volumeName)
) {
continue; // Will be handled as discovered volume
}
mountConfigs.push({
mountId: mount.mountId,
type: mount.type,
hostPath: mount.hostPath,
volumeName: mount.volumeName,
mountPath: mount.mountPath,
content: mount.content,
filePath: mount.filePath,
});
}
const allVolumeConfigs: MountTransferConfig[] = [
...discoveredVolumes.map((v) => ({
mountId: `docker-${v}`,
type: "volume" as const,
volumeName: v,
mountPath: "/data",
})),
...mountConfigs,
];
const targetBasePath = getServiceBasePath(serviceType, appName, true);
const preflight = await runPreflightChecks(
targetServerId,
targetBasePath,
0,
allVolumeConfigs,
(msg) => reportProgress("preparing", msg),
);
if (!preflight.passed) {
return { success: false, errors: preflight.errors };
}
// Phase 2: Sync service directory
if (hasServiceDirectory(serviceType)) {
reportProgress("syncing_directory", "Syncing service directory...");
const sourcePath = getServiceBasePath(
serviceType,
appName,
!!sourceServerId,
);
try {
await syncDirectory(
sourceServerId,
targetServerId,
sourcePath,
targetBasePath,
(msg) => reportProgress("syncing_directory", msg),
);
reportProgress("syncing_directory", "Service directory synced");
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
errors.push(`Failed to sync service directory: ${msg}`);
reportProgress("syncing_directory", `Error: ${msg}`);
}
}
// Phase 3: Sync Traefik config
if (serviceType === "application" || serviceType === "compose") {
reportProgress("syncing_traefik", "Syncing Traefik configuration...");
try {
await syncTraefikConfig(
sourceServerId,
targetServerId,
appName,
(msg) => reportProgress("syncing_traefik", msg),
);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
errors.push(`Failed to sync Traefik config: ${msg}`);
reportProgress("syncing_traefik", `Error: ${msg}`);
}
}
// Phase 4: Sync all discovered Docker volumes
reportProgress("syncing_mounts", "Syncing Docker volumes...");
for (const volumeName of discoveredVolumes) {
reportProgress("syncing_mounts", `Syncing volume: ${volumeName}`);
try {
await syncDockerVolume(
sourceServerId,
targetServerId,
volumeName,
(msg) => reportProgress("syncing_mounts", msg),
);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
errors.push(`Failed to sync volume ${volumeName}: ${msg}`);
reportProgress("syncing_mounts", `Error: ${msg}`);
}
}
// Phase 5: Sync user-defined mounts (bind mounts, etc.)
for (const mountConfig of mountConfigs) {
const mountLabel =
mountConfig.volumeName || mountConfig.hostPath || mountConfig.mountPath;
reportProgress("syncing_mounts", `Syncing: ${mountLabel}`);
try {
await syncMount(
sourceServerId,
targetServerId,
mountConfig,
decisions,
(msg) => reportProgress("syncing_mounts", msg),
);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
errors.push(`Failed to sync mount ${mountLabel}: ${msg}`);
reportProgress("syncing_mounts", `Error: ${msg}`);
}
}
if (errors.length > 0) {
reportProgress(
"failed",
`Transfer completed with errors: ${errors.join(", ")}`,
);
return { success: false, errors };
}
reportProgress("completed", "Transfer completed successfully!");
return { success: true, errors: [] };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
reportProgress("failed", `Transfer failed: ${message}`);
return { success: false, errors: [message] };
}
};

View File

@@ -17,9 +17,6 @@ export function getProviderName(apiUrl: string) {
if (apiUrl.includes(":11434") || apiUrl.includes("ollama")) return "ollama";
if (apiUrl.includes("api.deepinfra.com")) return "deepinfra";
if (apiUrl.includes("generativelanguage.googleapis.com")) return "gemini";
if (apiUrl.includes("openrouter.ai")) return "openrouter";
if (apiUrl.includes("api.z.ai")) return "zai";
if (apiUrl.includes("api.minimax.io")) return "minimax";
return "custom";
}
@@ -90,30 +87,6 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
Authorization: `Bearer ${config.apiKey}`,
},
});
case "openrouter":
return createOpenAICompatible({
name: "openrouter",
baseURL: config.apiUrl,
headers: {
Authorization: `Bearer ${config.apiKey}`,
},
});
case "zai":
return createOpenAICompatible({
name: "zai",
baseURL: config.apiUrl,
headers: {
Authorization: `Bearer ${config.apiKey}`,
},
});
case "minimax":
return createOpenAICompatible({
name: "minimax",
baseURL: config.apiUrl,
headers: {
Authorization: `Bearer ${config.apiKey}`,
},
});
case "custom":
return createOpenAICompatible({
name: "custom",

View File

@@ -106,7 +106,6 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
const envFilePath = join(dirname(composeFilePath), ".env");
let envContent = `APP_NAME=${appName}\n`;
envContent += `COMPOSE_PROJECT_NAME=${appName}\n`;
envContent += env || "";
if (!envContent.includes("DOCKER_CONFIG")) {
envContent += "\nDOCKER_CONFIG=/root/.docker";

View File

@@ -240,13 +240,14 @@ export const sendBuildErrorNotifications = async ({
value: `\`\`\`${errorMessage}\`\`\``,
short: false,
},
],
actions: [
{
title: "Details",
value: `<${buildLink}|View Build Details>`,
short: false,
type: "button",
text: "View Build Details",
url: buildLink,
},
],
mrkdwn_in: ["fields"],
},
],
});

View File

@@ -256,13 +256,14 @@ export const sendBuildSuccessNotifications = async ({
value: date.toLocaleString(),
short: true,
},
],
actions: [
{
title: "Details",
value: `<${buildLink}|View Build Details>`,
short: false,
type: "button",
text: "View Build Details",
url: buildLink,
},
],
mrkdwn_in: ["fields"],
},
],
});

View File

@@ -19,7 +19,6 @@ export const sendEmailNotification = async (
connection: typeof email.$inferInsert,
subject: string,
htmlContent: string,
attachments?: { filename: string; content: Buffer }[],
) => {
try {
const {
@@ -42,7 +41,6 @@ export const sendEmailNotification = async (
subject,
html: htmlContent,
textEncoding: "base64",
attachments,
});
} catch (err) {
console.log(err);

View File

@@ -151,18 +151,16 @@ export const createRouterConfig = async (
routerConfig.middlewares?.push("redirect-to-https");
} else {
// Add path rewriting middleware if needed
// stripPrefix must come before addPrefix so Traefik strips the
// public path first, then prepends the internal path.
if (stripPath && path && path !== "/") {
const stripMiddleware = `stripprefix-${appName}-${uniqueConfigKey}`;
routerConfig.middlewares?.push(stripMiddleware);
}
if (internalPath && internalPath !== "/" && internalPath !== path) {
const pathMiddleware = `addprefix-${appName}-${uniqueConfigKey}`;
routerConfig.middlewares?.push(pathMiddleware);
}
if (stripPath && path && path !== "/") {
const stripMiddleware = `stripprefix-${appName}-${uniqueConfigKey}`;
routerConfig.middlewares?.push(stripMiddleware);
}
// redirects - skip for preview deployments as wildcard subdomains
// should not inherit parent redirect rules (e.g., www-redirect)
if (domain.domainType !== "preview") {

View File

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

View File

@@ -1,100 +0,0 @@
import { execAsync, execAsyncRemote } from "../process/execAsync";
import type { MountTransferConfig } from "./types";
const execOnServer = async (
serverId: string | null,
command: string,
): Promise<{ stdout: string; stderr: string }> => {
if (serverId) {
return execAsyncRemote(serverId, command);
}
return execAsync(command);
};
export const ensureDirectoryExists = async (
serverId: string | null,
dirPath: string,
): Promise<void> => {
await execOnServer(serverId, `mkdir -p "${dirPath}"`);
};
export const ensureVolumeExists = async (
serverId: string | null,
volumeName: string,
): Promise<void> => {
await execOnServer(
serverId,
`docker volume inspect ${volumeName} > /dev/null 2>&1 || docker volume create ${volumeName}`,
);
};
export const checkDiskSpace = async (
serverId: string | null,
path: string,
): Promise<number> => {
const { stdout } = await execOnServer(
serverId,
`df -B1 "${path}" | tail -1 | awk '{print $4}'`,
);
return Number.parseInt(stdout.trim(), 10);
};
export const runPreflightChecks = async (
targetServerId: string,
targetBasePath: string,
requiredBytes: number,
mounts: MountTransferConfig[],
onLog?: (message: string) => void,
): Promise<{ passed: boolean; errors: string[] }> => {
const errors: string[] = [];
onLog?.("Checking disk space on target server...");
try {
const availableBytes = await checkDiskSpace(targetServerId, "/");
if (availableBytes < requiredBytes * 1.2) {
errors.push(
`Insufficient disk space on target server. Required: ${formatBytes(requiredBytes)}, Available: ${formatBytes(availableBytes)}`,
);
}
} catch {
errors.push("Failed to check disk space on target server");
}
onLog?.("Ensuring target directories exist...");
try {
await ensureDirectoryExists(targetServerId, targetBasePath);
} catch {
errors.push(`Failed to create directory: ${targetBasePath}`);
}
for (const mount of mounts) {
if (mount.type === "volume" && mount.volumeName) {
onLog?.(`Ensuring volume exists: ${mount.volumeName}`);
try {
await ensureVolumeExists(targetServerId, mount.volumeName);
} catch {
errors.push(`Failed to create volume: ${mount.volumeName}`);
}
} else if (mount.type === "bind" && mount.hostPath) {
onLog?.(`Ensuring bind mount path exists: ${mount.hostPath}`);
try {
await ensureDirectoryExists(targetServerId, mount.hostPath);
} catch {
errors.push(`Failed to create directory: ${mount.hostPath}`);
}
}
}
return {
passed: errors.length === 0,
errors,
};
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
};

View File

@@ -1,300 +0,0 @@
import { execAsync, execAsyncRemote } from "../process/execAsync";
import type {
ConflictStatus,
FileConflict,
FileInfo,
MountTransferConfig,
} from "./types";
const execOnServer = async (
serverId: string | null,
command: string,
): Promise<{ stdout: string; stderr: string }> => {
if (serverId) {
return execAsyncRemote(serverId, command);
}
return execAsync(command);
};
export const scanDirectory = async (
serverId: string | null,
dirPath: string,
): Promise<FileInfo[]> => {
// Check if directory exists first
try {
const { stdout: exists } = await execOnServer(
serverId,
`test -d "${dirPath}" && echo "yes" || echo "no"`,
);
if (exists.trim() !== "yes") {
return [];
}
} catch {
return [];
}
// Use find + stat -c (POSIX-compatible on Linux)
// stat -c works on GNU coreutils (Debian, Ubuntu, etc.)
const command = `find "${dirPath}" -type f -printf '%p|%s|%T@\\n' 2>/dev/null`;
try {
const { stdout } = await execOnServer(serverId, command);
if (!stdout.trim()) return [];
return stdout
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const parts = line.split("|");
const filePath = parts[0] || "";
const size = parts[1] || "0";
const modifiedAt = parts[2] || "0";
return {
path: filePath.replace(dirPath, "").replace(/^\//, ""),
size: Number.parseInt(size, 10),
modifiedAt: Math.floor(Number.parseFloat(modifiedAt)),
};
})
.filter((f) => f.path);
} catch {
// Fallback: try simpler ls-based approach
try {
const { stdout } = await execOnServer(
serverId,
`find "${dirPath}" -type f 2>/dev/null`,
);
if (!stdout.trim()) return [];
return stdout
.trim()
.split("\n")
.filter(Boolean)
.map((filePath) => ({
path: filePath.replace(dirPath, "").replace(/^\//, ""),
size: 0,
modifiedAt: 0,
}))
.filter((f) => f.path);
} catch {
return [];
}
}
};
export const scanDockerVolume = async (
serverId: string | null,
volumeName: string,
): Promise<FileInfo[]> => {
// First check if volume exists
try {
const { stdout: exists } = await execOnServer(
serverId,
`docker volume inspect "${volumeName}" >/dev/null 2>&1 && echo "yes" || echo "no"`,
);
if (exists.trim() !== "yes") {
return [];
}
} catch {
return [];
}
// Use busybox/alpine stat format (-c '%n|%s|%Y')
const command = `docker run --rm -v "${volumeName}":/volume:ro alpine sh -c 'find /volume -type f -exec stat -c "%n|%s|%Y" {} + 2>/dev/null || find /volume -type f 2>/dev/null'`;
try {
const { stdout } = await execOnServer(serverId, command);
if (!stdout.trim()) return [];
return stdout
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const parts = line.split("|");
if (parts.length >= 3) {
return {
path: (parts[0] || "").replace(/^\/volume\/?/, ""),
size: Number.parseInt(parts[1] || "0", 10),
modifiedAt: Number.parseInt(parts[2] || "0", 10),
};
}
// Fallback: just file path
return {
path: line.replace(/^\/volume\/?/, ""),
size: 0,
modifiedAt: 0,
};
})
.filter((f) => f.path);
} catch {
return [];
}
};
export const getDirectorySize = async (
serverId: string | null,
dirPath: string,
): Promise<number> => {
try {
const { stdout } = await execOnServer(
serverId,
`du -sb "${dirPath}" 2>/dev/null | awk '{print $1}'`,
);
return Number.parseInt(stdout.trim(), 10) || 0;
} catch {
return 0;
}
};
export const getVolumeSize = async (
serverId: string | null,
volumeName: string,
): Promise<number> => {
try {
const { stdout } = await execOnServer(
serverId,
`docker run --rm -v "${volumeName}":/volume:ro alpine du -sb /volume 2>/dev/null | awk '{print $1}'`,
);
return Number.parseInt(stdout.trim(), 10) || 0;
} catch {
return 0;
}
};
/**
* List all Docker volumes belonging to a compose project.
* Docker compose automatically labels volumes with com.docker.compose.project
*/
export const listComposeVolumes = async (
serverId: string | null,
projectName: string,
): Promise<string[]> => {
try {
const { stdout } = await execOnServer(
serverId,
`docker volume ls --filter "label=com.docker.compose.project=${projectName}" --format "{{.Name}}" 2>/dev/null`,
);
if (!stdout.trim()) return [];
return stdout.trim().split("\n").filter(Boolean);
} catch {
return [];
}
};
/**
* List all Docker volumes that match a prefix pattern (appName_*).
* Fallback for when compose labels are not available.
*/
export const listVolumesByPrefix = async (
serverId: string | null,
prefix: string,
): Promise<string[]> => {
try {
const { stdout } = await execOnServer(
serverId,
`docker volume ls --format "{{.Name}}" 2>/dev/null | grep "^${prefix}" || true`,
);
if (!stdout.trim()) return [];
return stdout.trim().split("\n").filter(Boolean);
} catch {
return [];
}
};
export const computeFileHash = async (
serverId: string | null,
filePath: string,
): Promise<string> => {
try {
const { stdout } = await execOnServer(
serverId,
`md5sum "${filePath}" 2>/dev/null | awk '{print $1}'`,
);
return stdout.trim();
} catch {
return "";
}
};
export const scanMount = async (
serverId: string | null,
mount: MountTransferConfig,
): Promise<FileInfo[]> => {
if (mount.type === "volume" && mount.volumeName) {
return scanDockerVolume(serverId, mount.volumeName);
}
if (mount.type === "bind" && mount.hostPath) {
return scanDirectory(serverId, mount.hostPath);
}
return [];
};
export const compareFileLists = (
sourceFiles: FileInfo[],
targetFiles: FileInfo[],
): FileConflict[] => {
const targetMap = new Map<string, FileInfo>();
for (const f of targetFiles) {
targetMap.set(f.path, f);
}
const conflicts: FileConflict[] = [];
for (const sourceFile of sourceFiles) {
const targetFile = targetMap.get(sourceFile.path);
if (!targetFile) {
conflicts.push({
path: sourceFile.path,
status: "missing_target",
sourceFile,
});
continue;
}
if (
sourceFile.size === targetFile.size &&
sourceFile.modifiedAt === targetFile.modifiedAt
) {
conflicts.push({
path: sourceFile.path,
status: "match",
sourceFile,
targetFile,
});
continue;
}
// Different size or time = conflict
let status: ConflictStatus;
if (sourceFile.modifiedAt > targetFile.modifiedAt) {
status = "newer_source";
} else if (targetFile.modifiedAt > sourceFile.modifiedAt) {
status = "newer_target";
} else {
status = "conflict";
}
conflicts.push({
path: sourceFile.path,
status,
sourceFile,
targetFile,
});
}
// Files only on target
for (const targetFile of targetFiles) {
if (!sourceFiles.some((sf) => sf.path === targetFile.path)) {
conflicts.push({
path: targetFile.path,
status: "newer_target",
sourceFile: { path: targetFile.path, size: 0, modifiedAt: 0 },
targetFile,
});
}
}
return conflicts;
};

View File

@@ -1,395 +0,0 @@
import { spawn } from "node:child_process";
import { findServerById } from "../../services/server";
import { Client } from "ssh2";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import type { ConflictDecision, MountTransferConfig } from "./types";
const execOnServer = async (
serverId: string | null,
command: string,
): Promise<{ stdout: string; stderr: string }> => {
if (serverId) {
return execAsyncRemote(serverId, command);
}
return execAsync(command);
};
/**
* Get a direct SSH connection to a server.
* Used for streaming binary data (tar pipes) that can't go through execAsyncRemote.
*/
const getSSHConnection = async (
serverId: string,
): Promise<{ conn: Client }> => {
const server = await findServerById(serverId);
if (!server.sshKeyId) {
throw new Error(`No SSH key configured for server ${server.name}`);
}
return new Promise((resolve, reject) => {
const conn = new Client();
conn
.on("ready", () => {
resolve({ conn });
})
.on("error", (err) => {
reject(
new Error(
`SSH connection failed to ${server.name} (${server.ipAddress}): ${err.message}`,
),
);
})
.connect({
host: server.ipAddress,
port: server.port,
username: server.username,
privateKey: server.sshKey?.privateKey,
});
});
};
/**
* Pipe a tar stream from source SSH connection to target SSH connection.
*/
const pipeSSH = (
sourceConn: Client,
targetConn: Client,
sourceCmd: string,
targetCmd: string,
onLog?: (message: string) => void,
): Promise<void> => {
return new Promise((resolve, reject) => {
sourceConn.exec(sourceCmd, (err, sourceStream) => {
if (err) return reject(new Error(`Source exec failed: ${err.message}`));
targetConn.exec(targetCmd, (err2, targetStream) => {
if (err2)
return reject(new Error(`Target exec failed: ${err2.message}`));
let totalBytes = 0;
sourceStream.on("data", (chunk: Buffer) => {
totalBytes += chunk.length;
targetStream.write(chunk);
});
sourceStream.on("end", () => {
targetStream.end();
});
targetStream.on("close", () => {
onLog?.(
`Transferred ${(totalBytes / 1024 / 1024).toFixed(2)} MB`,
);
resolve();
});
sourceStream.on("error", (e: Error) =>
reject(new Error(`Source stream error: ${e.message}`)),
);
targetStream.on("error", (e: Error) =>
reject(new Error(`Target stream error: ${e.message}`)),
);
});
});
});
};
/**
* Stream data from local tar command into a remote SSH command.
*/
const pipeLocalToRemote = (
targetConn: Client,
localCmd: string,
localArgs: string[],
remoteCmd: string,
onLog?: (message: string) => void,
): Promise<void> => {
return new Promise((resolve, reject) => {
const localProcess = spawn(localCmd, localArgs, {
stdio: ["ignore", "pipe", "pipe"],
});
targetConn.exec(remoteCmd, (err, targetStream) => {
if (err) {
localProcess.kill();
return reject(new Error(`Remote exec failed: ${err.message}`));
}
let totalBytes = 0;
localProcess.stdout.on("data", (chunk: Buffer) => {
totalBytes += chunk.length;
targetStream.write(chunk);
});
localProcess.stdout.on("end", () => {
targetStream.end();
});
targetStream.on("close", () => {
onLog?.(
`Transferred ${(totalBytes / 1024 / 1024).toFixed(2)} MB`,
);
resolve();
});
localProcess.on("error", (e) => reject(e));
targetStream.on("error", (e: Error) => reject(e));
});
});
};
/**
* Stream data from a remote SSH command into a local tar command.
*/
const pipeRemoteToLocal = (
sourceConn: Client,
remoteCmd: string,
localCmd: string,
localArgs: string[],
onLog?: (message: string) => void,
): Promise<void> => {
return new Promise((resolve, reject) => {
const localProcess = spawn(localCmd, localArgs, {
stdio: ["pipe", "pipe", "pipe"],
});
sourceConn.exec(remoteCmd, (err, sourceStream) => {
if (err) {
localProcess.kill();
return reject(new Error(`Remote exec failed: ${err.message}`));
}
let totalBytes = 0;
sourceStream.on("data", (chunk: Buffer) => {
totalBytes += chunk.length;
localProcess.stdin.write(chunk);
});
sourceStream.on("end", () => {
localProcess.stdin.end();
});
localProcess.on("close", (code: number) => {
onLog?.(
`Transferred ${(totalBytes / 1024 / 1024).toFixed(2)} MB`,
);
if (code === 0) resolve();
else reject(new Error(`Local process exited with code ${code}`));
});
sourceStream.on("error", (e: Error) => reject(e));
localProcess.on("error", (e) => reject(e));
});
});
};
export const syncDirectory = async (
sourceServerId: string | null,
targetServerId: string,
sourcePath: string,
targetPath: string,
onLog?: (message: string) => void,
): Promise<void> => {
onLog?.(`Syncing directory: ${sourcePath}${targetPath}`);
// Ensure target directory exists
await execOnServer(targetServerId, `mkdir -p "${targetPath}"`);
if (sourceServerId && targetServerId) {
// Remote → Remote: pipe tar directly between SSH connections
onLog?.("Using direct SSH pipe for remote-to-remote transfer...");
const [source, target] = await Promise.all([
getSSHConnection(sourceServerId),
getSSHConnection(targetServerId),
]);
try {
await pipeSSH(
source.conn,
target.conn,
`tar czf - -C "${sourcePath}" . 2>/dev/null`,
`tar xzf - -C "${targetPath}"`,
onLog,
);
} finally {
source.conn.end();
target.conn.end();
}
} else if (!sourceServerId && targetServerId) {
// Local → Remote
onLog?.("Transferring from local to remote...");
const { conn } = await getSSHConnection(targetServerId);
try {
await pipeLocalToRemote(
conn,
"tar",
["czf", "-", "-C", sourcePath, "."],
`tar xzf - -C "${targetPath}"`,
onLog,
);
} finally {
conn.end();
}
} else if (sourceServerId && !targetServerId) {
// Remote → Local
onLog?.("Transferring from remote to local...");
await execAsync(`mkdir -p "${targetPath}"`);
const { conn } = await getSSHConnection(sourceServerId);
try {
await pipeRemoteToLocal(
conn,
`tar czf - -C "${sourcePath}" . 2>/dev/null`,
"tar",
["xzf", "-", "-C", targetPath],
onLog,
);
} finally {
conn.end();
}
}
onLog?.(`Directory synced successfully: ${targetPath}`);
};
export const syncDockerVolume = async (
sourceServerId: string | null,
targetServerId: string,
volumeName: string,
onLog?: (message: string) => void,
): Promise<void> => {
onLog?.(`Syncing Docker volume: ${volumeName}`);
// Ensure volume exists on target
await execOnServer(
targetServerId,
`docker volume inspect "${volumeName}" > /dev/null 2>&1 || docker volume create "${volumeName}"`,
);
const srcTarCmd = `docker run --rm -v "${volumeName}":/volume:ro alpine tar czf - -C /volume . 2>/dev/null`;
const dstTarCmd = `docker run --rm -i -v "${volumeName}":/volume alpine tar xzf - -C /volume`;
if (sourceServerId && targetServerId) {
// Remote → Remote
onLog?.("Using direct SSH pipe for volume transfer...");
const [source, target] = await Promise.all([
getSSHConnection(sourceServerId),
getSSHConnection(targetServerId),
]);
try {
await pipeSSH(source.conn, target.conn, srcTarCmd, dstTarCmd, onLog);
} finally {
source.conn.end();
target.conn.end();
}
} else if (!sourceServerId && targetServerId) {
// Local → Remote
onLog?.("Transferring volume from local to remote...");
const { conn } = await getSSHConnection(targetServerId);
try {
await pipeLocalToRemote(
conn,
"docker",
[
"run", "--rm",
"-v", `${volumeName}:/volume:ro`,
"alpine", "tar", "czf", "-", "-C", "/volume", ".",
],
dstTarCmd,
onLog,
);
} finally {
conn.end();
}
} else if (sourceServerId && !targetServerId) {
// Remote → Local
onLog?.("Transferring volume from remote to local...");
const { conn } = await getSSHConnection(sourceServerId);
try {
await pipeRemoteToLocal(
conn,
srcTarCmd,
"docker",
[
"run", "--rm", "-i",
"-v", `${volumeName}:/volume`,
"alpine", "tar", "xzf", "-", "-C", "/volume",
],
onLog,
);
} finally {
conn.end();
}
}
onLog?.(`Volume synced successfully: ${volumeName}`);
};
export const syncMount = async (
sourceServerId: string | null,
targetServerId: string,
mount: MountTransferConfig,
_decisions: Record<string, ConflictDecision>,
onLog?: (message: string) => void,
): Promise<void> => {
if (mount.type === "volume" && mount.volumeName) {
await syncDockerVolume(
sourceServerId,
targetServerId,
mount.volumeName,
onLog,
);
} else if (mount.type === "bind" && mount.hostPath) {
await syncDirectory(
sourceServerId,
targetServerId,
mount.hostPath,
mount.hostPath,
onLog,
);
} else if (mount.type === "file" && mount.content) {
onLog?.("File mount will be recreated from database content during deploy");
}
};
export const syncTraefikConfig = async (
sourceServerId: string | null,
targetServerId: string,
appName: string,
onLog?: (message: string) => void,
): Promise<void> => {
onLog?.(`Syncing Traefik config for: ${appName}`);
const configPath = "/etc/dokploy/traefik/dynamic";
const configFile = `${configPath}/${appName}.yml`;
let configContent: string;
try {
const { stdout } = await execOnServer(
sourceServerId,
`cat "${configFile}" 2>/dev/null`,
);
configContent = stdout;
} catch {
onLog?.("No Traefik config found on source, skipping");
return;
}
if (!configContent.trim()) {
onLog?.("Empty Traefik config on source, skipping");
return;
}
await execOnServer(targetServerId, `mkdir -p "${configPath}"`);
const b64 = Buffer.from(configContent).toString("base64");
await execOnServer(
targetServerId,
`echo "${b64}" | base64 -d > "${configFile}"`,
);
onLog?.("Traefik config synced successfully");
};

View File

@@ -1,91 +0,0 @@
export type ServiceType =
| "application"
| "compose"
| "postgres"
| "mysql"
| "mariadb"
| "mongo"
| "redis";
export interface FileInfo {
path: string;
size: number;
modifiedAt: number;
hash?: string;
}
export type ConflictStatus =
| "missing_target"
| "newer_source"
| "newer_target"
| "conflict"
| "match";
export interface FileConflict {
path: string;
status: ConflictStatus;
sourceFile: FileInfo;
targetFile?: FileInfo;
}
export interface MountTransferConfig {
mountId: string;
type: "bind" | "volume" | "file";
hostPath?: string | null;
volumeName?: string | null;
mountPath: string;
content?: string | null;
filePath?: string | null;
}
export interface TransferScanResult {
serviceDirectory: {
files: FileConflict[];
totalSize: number;
};
traefikConfig: {
exists: boolean;
hasConflict: boolean;
};
mounts: Array<{
mount: MountTransferConfig;
files: FileConflict[];
totalSize: number;
}>;
totalTransferSize: number;
totalFiles: number;
conflicts: FileConflict[];
}
export type ConflictDecision = "skip" | "overwrite";
export interface TransferProgress {
phase:
| "preparing"
| "syncing_directory"
| "syncing_traefik"
| "syncing_mounts"
| "updating_database"
| "completed"
| "failed";
currentFile?: string;
processedFiles: number;
totalFiles: number;
transferredBytes: number;
totalBytes: number;
percentage: number;
message?: string;
}
export interface TransferOptions {
serviceId: string;
serviceType: ServiceType;
appName: string;
sourceServerId: string | null;
targetServerId: string;
}
export interface TransferResult {
success: boolean;
errors: string[];
}

View File

@@ -3,12 +3,10 @@ export const sendEmail = async ({
email,
subject,
text,
attachments,
}: {
email: string;
subject: string;
text: string;
attachments?: { filename: string; content: Buffer }[];
}) => {
await sendEmailNotification(
{
@@ -21,7 +19,6 @@ export const sendEmail = async ({
},
subject,
text,
attachments,
);
return true;

30
pnpm-lock.yaml generated
View File

@@ -176,9 +176,6 @@ importers:
'@radix-ui/react-collapsible':
specifier: ^1.1.11
version: 1.1.12(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-context-menu':
specifier: ^2.2.16
version: 2.2.16(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-dialog':
specifier: ^1.1.14
version: 1.1.15(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -2804,19 +2801,6 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-context-menu@2.2.16':
resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==}
peerDependencies:
'@types/react': 18.3.5
'@types/react-dom': 18.3.0
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-context@1.0.0':
resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==}
peerDependencies:
@@ -10649,20 +10633,6 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.5
'@radix-ui/react-context-menu@2.2.16(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@18.3.5)(react@18.2.0)
'@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.5)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.5)(react@18.2.0)
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
optionalDependencies:
'@types/react': 18.3.5
'@types/react-dom': 18.3.0
'@radix-ui/react-context@1.0.0(react@18.2.0)':
dependencies:
'@babel/runtime': 7.28.6