mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-18 21:55:24 +02:00
Compare commits
4 Commits
feat/servi
...
fix/webhoo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e2eb7213d | ||
|
|
dcb95374da | ||
|
|
36e131cf12 | ||
|
|
17b4c0fc58 |
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 || ""}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%]"
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -3,15 +3,13 @@ import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes
|
||||
import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command";
|
||||
import { ShowClusterSettings } from "../application/advanced/cluster/show-cluster-settings";
|
||||
import { RebuildDatabase } from "./rebuild-database";
|
||||
import { TransferService } from "./transfer-service";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "libsql" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
|
||||
serverId?: string | null;
|
||||
}
|
||||
|
||||
export const ShowDatabaseAdvancedSettings = ({ id, type, serverId }: Props) => {
|
||||
export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5">
|
||||
<ShowCustomCommand id={id} type={type} />
|
||||
@@ -25,13 +23,6 @@ export const ShowDatabaseAdvancedSettings = ({ id, type, serverId }: Props) => {
|
||||
<ShowVolumes id={id} type={type} />
|
||||
<ShowResources id={id} type={type} />
|
||||
<RebuildDatabase id={id} type={type} />
|
||||
{type !== "libsql" && (
|
||||
<TransferService
|
||||
serviceId={id}
|
||||
serviceType={type}
|
||||
currentServerId={serverId ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,596 +0,0 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRightLeft,
|
||||
Loader2,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
||||
import type { LogLine } from "@/components/dashboard/docker/logs/utils";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type ServiceType =
|
||||
| "application"
|
||||
| "compose"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "redis";
|
||||
|
||||
interface TransferServiceProps {
|
||||
serviceId: string;
|
||||
serviceType: ServiceType;
|
||||
currentServerId: string | null;
|
||||
}
|
||||
|
||||
interface ScanResult {
|
||||
serviceDirectory: {
|
||||
files: Array<{
|
||||
path: string;
|
||||
status: string;
|
||||
sourceFile: { path: string; size: number; modifiedAt: number };
|
||||
targetFile?: { path: string; size: number; modifiedAt: number };
|
||||
}>;
|
||||
totalSize: number;
|
||||
};
|
||||
traefikConfig: {
|
||||
exists: boolean;
|
||||
hasConflict: boolean;
|
||||
};
|
||||
mounts: Array<{
|
||||
mount: {
|
||||
mountId: string;
|
||||
type: string;
|
||||
volumeName?: string | null;
|
||||
hostPath?: string | null;
|
||||
mountPath: string;
|
||||
};
|
||||
files: Array<{
|
||||
path: string;
|
||||
status: string;
|
||||
}>;
|
||||
totalSize: number;
|
||||
}>;
|
||||
totalTransferSize: number;
|
||||
totalFiles: number;
|
||||
conflicts: Array<{
|
||||
path: string;
|
||||
status: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const useScanMutation = (serviceType: ServiceType) => {
|
||||
const mutations = {
|
||||
application: api.application.transferScan.useMutation(),
|
||||
compose: api.compose.transferScan.useMutation(),
|
||||
postgres: api.postgres.transferScan.useMutation(),
|
||||
mysql: api.mysql.transferScan.useMutation(),
|
||||
mariadb: api.mariadb.transferScan.useMutation(),
|
||||
mongo: api.mongo.transferScan.useMutation(),
|
||||
redis: api.redis.transferScan.useMutation(),
|
||||
};
|
||||
return mutations[serviceType];
|
||||
};
|
||||
|
||||
const getServiceIdKey = (serviceType: ServiceType): string => {
|
||||
const map: Record<ServiceType, string> = {
|
||||
application: "applicationId",
|
||||
compose: "composeId",
|
||||
postgres: "postgresId",
|
||||
mysql: "mysqlId",
|
||||
mariadb: "mariadbId",
|
||||
mongo: "mongoId",
|
||||
redis: "redisId",
|
||||
};
|
||||
return map[serviceType];
|
||||
};
|
||||
|
||||
export const TransferService = ({
|
||||
serviceId,
|
||||
serviceType,
|
||||
currentServerId,
|
||||
}: TransferServiceProps) => {
|
||||
const [targetServerId, setTargetServerId] = useState<string>("");
|
||||
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
||||
const [step, setStep] = useState<"select" | "scan" | "confirm">("select");
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
// Drawer logs state
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||
const [isTransferring, setIsTransferring] = useState(false);
|
||||
|
||||
const { data: servers } = api.server.all.useQuery();
|
||||
const utils = api.useUtils();
|
||||
const scan = useScanMutation(serviceType);
|
||||
|
||||
const idKey = getServiceIdKey(serviceType);
|
||||
|
||||
const availableServers = servers?.filter(
|
||||
(s) => s.serverId !== currentServerId,
|
||||
);
|
||||
|
||||
const selectedServer = servers?.find((s) => s.serverId === targetServerId);
|
||||
|
||||
// Subscription for transfer with logs
|
||||
const subscriptionInput = {
|
||||
[idKey]: serviceId,
|
||||
targetServerId: targetServerId || "placeholder",
|
||||
decisions: {},
|
||||
};
|
||||
|
||||
const useTransferSubscription = (sType: ServiceType) => {
|
||||
api.application.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "application",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
api.compose.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "compose",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
api.postgres.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "postgres",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
api.mysql.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "mysql",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
api.mariadb.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "mariadb",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
api.mongo.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "mongo",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
api.redis.transferWithLogs.useSubscription(subscriptionInput as any, {
|
||||
enabled: isTransferring && sType === "redis",
|
||||
onData: handleLogData,
|
||||
onError: handleLogError,
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogData = (log: string) => {
|
||||
if (!isDrawerOpen) {
|
||||
setIsDrawerOpen(true);
|
||||
}
|
||||
|
||||
// Try to parse as JSON progress
|
||||
try {
|
||||
const progress = JSON.parse(log);
|
||||
if (progress.message) {
|
||||
const logLine: LogLine = {
|
||||
rawTimestamp: new Date().toISOString(),
|
||||
timestamp: new Date(),
|
||||
message: `[${progress.phase || "transfer"}] ${progress.message}`,
|
||||
};
|
||||
setFilteredLogs((prev) => [...prev, logLine]);
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
// Not JSON, treat as plain text
|
||||
}
|
||||
|
||||
const logLine: LogLine = {
|
||||
rawTimestamp: new Date().toISOString(),
|
||||
timestamp: new Date(),
|
||||
message: log,
|
||||
};
|
||||
setFilteredLogs((prev) => [...prev, logLine]);
|
||||
|
||||
if (
|
||||
log.includes("completed successfully") ||
|
||||
log.includes("Deployment queued") ||
|
||||
log.includes("Deployment started")
|
||||
) {
|
||||
setTimeout(() => {
|
||||
setIsTransferring(false);
|
||||
utils.invalidate();
|
||||
toast.success("Transfer and deployment completed!");
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
if (log.includes("Transfer failed") || log.includes("Transfer error")) {
|
||||
setIsTransferring(false);
|
||||
toast.error("Transfer failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogError = (error: unknown) => {
|
||||
console.error("Transfer subscription error:", error);
|
||||
setIsTransferring(false);
|
||||
const logLine: LogLine = {
|
||||
rawTimestamp: new Date().toISOString(),
|
||||
timestamp: new Date(),
|
||||
message: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
setFilteredLogs((prev) => [...prev, logLine]);
|
||||
};
|
||||
|
||||
// Register the subscription hooks (must be called unconditionally)
|
||||
useTransferSubscription(serviceType);
|
||||
|
||||
const handleScan = async () => {
|
||||
if (!targetServerId) {
|
||||
toast.error("Please select a target server");
|
||||
return;
|
||||
}
|
||||
|
||||
setStep("scan");
|
||||
try {
|
||||
const result = await scan.mutateAsync({
|
||||
[idKey]: serviceId,
|
||||
targetServerId,
|
||||
} as any);
|
||||
setScanResult(result as ScanResult);
|
||||
setStep("confirm");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Scan failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
setStep("select");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTransfer = async () => {
|
||||
setShowConfirm(false);
|
||||
setFilteredLogs([]);
|
||||
setIsTransferring(true);
|
||||
setIsDrawerOpen(true);
|
||||
|
||||
// Add initial log
|
||||
setFilteredLogs([
|
||||
{
|
||||
rawTimestamp: new Date().toISOString(),
|
||||
timestamp: new Date(),
|
||||
message: `Starting transfer to ${selectedServer?.name} (${selectedServer?.ipAddress})...`,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const isDbService = [
|
||||
"postgres",
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"mongo",
|
||||
"redis",
|
||||
].includes(serviceType);
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<ArrowRightLeft className="size-5" />
|
||||
Transfer Service
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Transfer this {serviceType} service to a different server. Source data
|
||||
is never modified or deleted.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!availableServers?.length ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Server className="size-4" />
|
||||
<span>
|
||||
No other servers available. Add a remote server first.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Step 1: Select target server */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">Target Server</span>
|
||||
<Select
|
||||
value={targetServerId}
|
||||
onValueChange={(value) => {
|
||||
setTargetServerId(value);
|
||||
setScanResult(null);
|
||||
setStep("select");
|
||||
}}
|
||||
disabled={isTransferring}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select target server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{availableServers.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverId}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{server.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{server.ipAddress}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Servers ({availableServers.length})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Scan button */}
|
||||
{step === "select" && targetServerId && (
|
||||
<Button
|
||||
onClick={handleScan}
|
||||
disabled={scan.isPending}
|
||||
variant="outline"
|
||||
>
|
||||
{scan.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Scanning...
|
||||
</>
|
||||
) : (
|
||||
"Scan for Transfer"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Step 2: Scan in progress */}
|
||||
{step === "scan" && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>
|
||||
Scanning source and target servers for files and
|
||||
conflicts...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Scan results + confirm */}
|
||||
{step === "confirm" && scanResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<h4 className="font-medium">Scan Results</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Total Files:
|
||||
</span>{" "}
|
||||
<span className="font-medium">
|
||||
{scanResult.totalFiles}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Transfer Size:
|
||||
</span>{" "}
|
||||
<span className="font-medium">
|
||||
{formatBytes(scanResult.totalTransferSize)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Volumes/Mounts:
|
||||
</span>{" "}
|
||||
<span className="font-medium">
|
||||
{scanResult.mounts.length}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
Conflicts:
|
||||
</span>{" "}
|
||||
<Badge
|
||||
variant={
|
||||
scanResult.conflicts.length > 0
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{scanResult.conflicts.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{scanResult.traefikConfig.exists && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Traefik Config:
|
||||
</span>{" "}
|
||||
<Badge variant="outline">Will be synced</Badge>
|
||||
</div>
|
||||
)}
|
||||
{scanResult.mounts.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Docker Volumes:
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{scanResult.mounts.map((m) => (
|
||||
<Badge
|
||||
key={m.mount.mountId}
|
||||
variant="outline"
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{m.mount.volumeName ||
|
||||
m.mount.hostPath ||
|
||||
m.mount.mountPath}
|
||||
{m.totalSize > 0 && (
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
({formatBytes(m.totalSize)})
|
||||
</span>
|
||||
)}
|
||||
{m.files.length > 0 && (
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
{m.files.length} files
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conflict list */}
|
||||
{scanResult.conflicts.length > 0 && (
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h4 className="font-medium text-sm">
|
||||
File Conflicts (will be overwritten)
|
||||
</h4>
|
||||
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||
{scanResult.conflicts.map((conflict) => (
|
||||
<div
|
||||
key={conflict.path}
|
||||
className="text-xs font-mono flex items-center gap-2"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px]"
|
||||
>
|
||||
{conflict.status}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{conflict.path}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning */}
|
||||
<div className="rounded-lg border border-yellow-500/50 bg-yellow-500/10 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
|
||||
<AlertTriangle className="size-4" />
|
||||
<span className="font-medium text-sm">
|
||||
Service Downtime Warning
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isDbService
|
||||
? "Stop the database service before transferring to avoid data corruption. After transfer completes, the service will be automatically deployed on the target server."
|
||||
: "The service will be unavailable during transfer. After transfer completes, the service will be automatically deployed on the target server."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Transfer button */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setStep("select");
|
||||
setScanResult(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowConfirm(true)}
|
||||
disabled={isTransferring}
|
||||
>
|
||||
<ArrowRightLeft className="mr-2 size-4" />
|
||||
Transfer to {selectedServer?.name}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Confirmation dialog */}
|
||||
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Service Transfer</AlertDialogTitle>
|
||||
<AlertDialogDescription className="space-y-2">
|
||||
<p>
|
||||
You are about to transfer this {serviceType} to{" "}
|
||||
<strong>{selectedServer?.name}</strong> (
|
||||
{selectedServer?.ipAddress}).
|
||||
</p>
|
||||
{scanResult && (
|
||||
<p>
|
||||
{scanResult.totalFiles} files (
|
||||
{formatBytes(scanResult.totalTransferSize)}) will be
|
||||
copied.
|
||||
{scanResult.mounts.length > 0 &&
|
||||
` ${scanResult.mounts.length} volume(s) will be transferred.`}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-yellow-600 dark:text-yellow-400 font-medium">
|
||||
The service will experience downtime during this
|
||||
process. After transfer, the service will be
|
||||
automatically deployed on the target server.
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleTransfer}>
|
||||
Confirm Transfer
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Drawer for transfer logs */}
|
||||
<DrawerLogs
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => {
|
||||
setIsDrawerOpen(false);
|
||||
if (!isTransferring) {
|
||||
setFilteredLogs([]);
|
||||
setStep("select");
|
||||
setScanResult(null);
|
||||
}
|
||||
}}
|
||||
filteredLogs={filteredLogs}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "user" ADD COLUMN "sendInvoiceNotifications" boolean DEFAULT false NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1156,13 +1156,6 @@
|
||||
"when": 1775369858244,
|
||||
"tag": "0164_slippery_sasquatch",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 165,
|
||||
"version": "7",
|
||||
"when": 1775845419261,
|
||||
"tag": "0165_abnormal_greymalkin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -303,7 +303,6 @@ const Mariadb = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={mariadbId}
|
||||
type="mariadb"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -307,7 +307,6 @@ const Mongo = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={mongoId}
|
||||
type="mongo"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -284,7 +284,6 @@ const MySql = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={mysqlId}
|
||||
type="mysql"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -292,7 +292,6 @@ const Postgresql = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={postgresId}
|
||||
type="postgres"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -296,7 +296,6 @@ const Redis = (
|
||||
<ShowDatabaseAdvancedSettings
|
||||
id={redisId}
|
||||
type="redis"
|
||||
serverId={data?.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -213,9 +213,3 @@ export const apiRebuildMariadb = createSchema
|
||||
mariadbId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiTransferMariadb = z.object({
|
||||
mariadbId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -210,9 +210,3 @@ export const apiRebuildMongo = createSchema
|
||||
mongoId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiTransferMongo = z.object({
|
||||
mongoId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -210,9 +210,3 @@ export const apiRebuildMysql = createSchema
|
||||
mysqlId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiTransferMysql = z.object({
|
||||
mysqlId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -204,9 +204,3 @@ export const apiRebuildPostgres = createSchema
|
||||
postgresId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiTransferPostgres = z.object({
|
||||
postgresId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -187,9 +187,3 @@ export const apiRebuildRedis = createSchema
|
||||
redisId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
export const apiTransferRedis = z.object({
|
||||
redisId: z.string().min(1),
|
||||
targetServerId: z.string().min(1),
|
||||
decisions: z.record(z.string(), z.enum(["skip", "overwrite"])).optional(),
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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] };
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from "./types";
|
||||
export * from "./scanner";
|
||||
export * from "./sync";
|
||||
export * from "./preflight";
|
||||
@@ -1,100 +0,0 @@
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import type { MountTransferConfig } from "./types";
|
||||
|
||||
const execOnServer = async (
|
||||
serverId: string | null,
|
||||
command: string,
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
if (serverId) {
|
||||
return execAsyncRemote(serverId, command);
|
||||
}
|
||||
return execAsync(command);
|
||||
};
|
||||
|
||||
export const ensureDirectoryExists = async (
|
||||
serverId: string | null,
|
||||
dirPath: string,
|
||||
): Promise<void> => {
|
||||
await execOnServer(serverId, `mkdir -p "${dirPath}"`);
|
||||
};
|
||||
|
||||
export const ensureVolumeExists = async (
|
||||
serverId: string | null,
|
||||
volumeName: string,
|
||||
): Promise<void> => {
|
||||
await execOnServer(
|
||||
serverId,
|
||||
`docker volume inspect ${volumeName} > /dev/null 2>&1 || docker volume create ${volumeName}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const checkDiskSpace = async (
|
||||
serverId: string | null,
|
||||
path: string,
|
||||
): Promise<number> => {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`df -B1 "${path}" | tail -1 | awk '{print $4}'`,
|
||||
);
|
||||
return Number.parseInt(stdout.trim(), 10);
|
||||
};
|
||||
|
||||
export const runPreflightChecks = async (
|
||||
targetServerId: string,
|
||||
targetBasePath: string,
|
||||
requiredBytes: number,
|
||||
mounts: MountTransferConfig[],
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<{ passed: boolean; errors: string[] }> => {
|
||||
const errors: string[] = [];
|
||||
|
||||
onLog?.("Checking disk space on target server...");
|
||||
try {
|
||||
const availableBytes = await checkDiskSpace(targetServerId, "/");
|
||||
if (availableBytes < requiredBytes * 1.2) {
|
||||
errors.push(
|
||||
`Insufficient disk space on target server. Required: ${formatBytes(requiredBytes)}, Available: ${formatBytes(availableBytes)}`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
errors.push("Failed to check disk space on target server");
|
||||
}
|
||||
|
||||
onLog?.("Ensuring target directories exist...");
|
||||
try {
|
||||
await ensureDirectoryExists(targetServerId, targetBasePath);
|
||||
} catch {
|
||||
errors.push(`Failed to create directory: ${targetBasePath}`);
|
||||
}
|
||||
|
||||
for (const mount of mounts) {
|
||||
if (mount.type === "volume" && mount.volumeName) {
|
||||
onLog?.(`Ensuring volume exists: ${mount.volumeName}`);
|
||||
try {
|
||||
await ensureVolumeExists(targetServerId, mount.volumeName);
|
||||
} catch {
|
||||
errors.push(`Failed to create volume: ${mount.volumeName}`);
|
||||
}
|
||||
} else if (mount.type === "bind" && mount.hostPath) {
|
||||
onLog?.(`Ensuring bind mount path exists: ${mount.hostPath}`);
|
||||
try {
|
||||
await ensureDirectoryExists(targetServerId, mount.hostPath);
|
||||
} catch {
|
||||
errors.push(`Failed to create directory: ${mount.hostPath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
passed: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
|
||||
};
|
||||
@@ -1,300 +0,0 @@
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import type {
|
||||
ConflictStatus,
|
||||
FileConflict,
|
||||
FileInfo,
|
||||
MountTransferConfig,
|
||||
} from "./types";
|
||||
|
||||
const execOnServer = async (
|
||||
serverId: string | null,
|
||||
command: string,
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
if (serverId) {
|
||||
return execAsyncRemote(serverId, command);
|
||||
}
|
||||
return execAsync(command);
|
||||
};
|
||||
|
||||
export const scanDirectory = async (
|
||||
serverId: string | null,
|
||||
dirPath: string,
|
||||
): Promise<FileInfo[]> => {
|
||||
// Check if directory exists first
|
||||
try {
|
||||
const { stdout: exists } = await execOnServer(
|
||||
serverId,
|
||||
`test -d "${dirPath}" && echo "yes" || echo "no"`,
|
||||
);
|
||||
if (exists.trim() !== "yes") {
|
||||
return [];
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use find + stat -c (POSIX-compatible on Linux)
|
||||
// stat -c works on GNU coreutils (Debian, Ubuntu, etc.)
|
||||
const command = `find "${dirPath}" -type f -printf '%p|%s|%T@\\n' 2>/dev/null`;
|
||||
|
||||
try {
|
||||
const { stdout } = await execOnServer(serverId, command);
|
||||
if (!stdout.trim()) return [];
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parts = line.split("|");
|
||||
const filePath = parts[0] || "";
|
||||
const size = parts[1] || "0";
|
||||
const modifiedAt = parts[2] || "0";
|
||||
return {
|
||||
path: filePath.replace(dirPath, "").replace(/^\//, ""),
|
||||
size: Number.parseInt(size, 10),
|
||||
modifiedAt: Math.floor(Number.parseFloat(modifiedAt)),
|
||||
};
|
||||
})
|
||||
.filter((f) => f.path);
|
||||
} catch {
|
||||
// Fallback: try simpler ls-based approach
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`find "${dirPath}" -type f 2>/dev/null`,
|
||||
);
|
||||
if (!stdout.trim()) return [];
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((filePath) => ({
|
||||
path: filePath.replace(dirPath, "").replace(/^\//, ""),
|
||||
size: 0,
|
||||
modifiedAt: 0,
|
||||
}))
|
||||
.filter((f) => f.path);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const scanDockerVolume = async (
|
||||
serverId: string | null,
|
||||
volumeName: string,
|
||||
): Promise<FileInfo[]> => {
|
||||
// First check if volume exists
|
||||
try {
|
||||
const { stdout: exists } = await execOnServer(
|
||||
serverId,
|
||||
`docker volume inspect "${volumeName}" >/dev/null 2>&1 && echo "yes" || echo "no"`,
|
||||
);
|
||||
if (exists.trim() !== "yes") {
|
||||
return [];
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use busybox/alpine stat format (-c '%n|%s|%Y')
|
||||
const command = `docker run --rm -v "${volumeName}":/volume:ro alpine sh -c 'find /volume -type f -exec stat -c "%n|%s|%Y" {} + 2>/dev/null || find /volume -type f 2>/dev/null'`;
|
||||
|
||||
try {
|
||||
const { stdout } = await execOnServer(serverId, command);
|
||||
if (!stdout.trim()) return [];
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const parts = line.split("|");
|
||||
if (parts.length >= 3) {
|
||||
return {
|
||||
path: (parts[0] || "").replace(/^\/volume\/?/, ""),
|
||||
size: Number.parseInt(parts[1] || "0", 10),
|
||||
modifiedAt: Number.parseInt(parts[2] || "0", 10),
|
||||
};
|
||||
}
|
||||
// Fallback: just file path
|
||||
return {
|
||||
path: line.replace(/^\/volume\/?/, ""),
|
||||
size: 0,
|
||||
modifiedAt: 0,
|
||||
};
|
||||
})
|
||||
.filter((f) => f.path);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getDirectorySize = async (
|
||||
serverId: string | null,
|
||||
dirPath: string,
|
||||
): Promise<number> => {
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`du -sb "${dirPath}" 2>/dev/null | awk '{print $1}'`,
|
||||
);
|
||||
return Number.parseInt(stdout.trim(), 10) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const getVolumeSize = async (
|
||||
serverId: string | null,
|
||||
volumeName: string,
|
||||
): Promise<number> => {
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`docker run --rm -v "${volumeName}":/volume:ro alpine du -sb /volume 2>/dev/null | awk '{print $1}'`,
|
||||
);
|
||||
return Number.parseInt(stdout.trim(), 10) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* List all Docker volumes belonging to a compose project.
|
||||
* Docker compose automatically labels volumes with com.docker.compose.project
|
||||
*/
|
||||
export const listComposeVolumes = async (
|
||||
serverId: string | null,
|
||||
projectName: string,
|
||||
): Promise<string[]> => {
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`docker volume ls --filter "label=com.docker.compose.project=${projectName}" --format "{{.Name}}" 2>/dev/null`,
|
||||
);
|
||||
if (!stdout.trim()) return [];
|
||||
return stdout.trim().split("\n").filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* List all Docker volumes that match a prefix pattern (appName_*).
|
||||
* Fallback for when compose labels are not available.
|
||||
*/
|
||||
export const listVolumesByPrefix = async (
|
||||
serverId: string | null,
|
||||
prefix: string,
|
||||
): Promise<string[]> => {
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`docker volume ls --format "{{.Name}}" 2>/dev/null | grep "^${prefix}" || true`,
|
||||
);
|
||||
if (!stdout.trim()) return [];
|
||||
return stdout.trim().split("\n").filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const computeFileHash = async (
|
||||
serverId: string | null,
|
||||
filePath: string,
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
serverId,
|
||||
`md5sum "${filePath}" 2>/dev/null | awk '{print $1}'`,
|
||||
);
|
||||
return stdout.trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export const scanMount = async (
|
||||
serverId: string | null,
|
||||
mount: MountTransferConfig,
|
||||
): Promise<FileInfo[]> => {
|
||||
if (mount.type === "volume" && mount.volumeName) {
|
||||
return scanDockerVolume(serverId, mount.volumeName);
|
||||
}
|
||||
if (mount.type === "bind" && mount.hostPath) {
|
||||
return scanDirectory(serverId, mount.hostPath);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const compareFileLists = (
|
||||
sourceFiles: FileInfo[],
|
||||
targetFiles: FileInfo[],
|
||||
): FileConflict[] => {
|
||||
const targetMap = new Map<string, FileInfo>();
|
||||
for (const f of targetFiles) {
|
||||
targetMap.set(f.path, f);
|
||||
}
|
||||
|
||||
const conflicts: FileConflict[] = [];
|
||||
|
||||
for (const sourceFile of sourceFiles) {
|
||||
const targetFile = targetMap.get(sourceFile.path);
|
||||
|
||||
if (!targetFile) {
|
||||
conflicts.push({
|
||||
path: sourceFile.path,
|
||||
status: "missing_target",
|
||||
sourceFile,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
sourceFile.size === targetFile.size &&
|
||||
sourceFile.modifiedAt === targetFile.modifiedAt
|
||||
) {
|
||||
conflicts.push({
|
||||
path: sourceFile.path,
|
||||
status: "match",
|
||||
sourceFile,
|
||||
targetFile,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Different size or time = conflict
|
||||
let status: ConflictStatus;
|
||||
if (sourceFile.modifiedAt > targetFile.modifiedAt) {
|
||||
status = "newer_source";
|
||||
} else if (targetFile.modifiedAt > sourceFile.modifiedAt) {
|
||||
status = "newer_target";
|
||||
} else {
|
||||
status = "conflict";
|
||||
}
|
||||
|
||||
conflicts.push({
|
||||
path: sourceFile.path,
|
||||
status,
|
||||
sourceFile,
|
||||
targetFile,
|
||||
});
|
||||
}
|
||||
|
||||
// Files only on target
|
||||
for (const targetFile of targetFiles) {
|
||||
if (!sourceFiles.some((sf) => sf.path === targetFile.path)) {
|
||||
conflicts.push({
|
||||
path: targetFile.path,
|
||||
status: "newer_target",
|
||||
sourceFile: { path: targetFile.path, size: 0, modifiedAt: 0 },
|
||||
targetFile,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
};
|
||||
@@ -1,395 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { findServerById } from "../../services/server";
|
||||
import { Client } from "ssh2";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import type { ConflictDecision, MountTransferConfig } from "./types";
|
||||
|
||||
const execOnServer = async (
|
||||
serverId: string | null,
|
||||
command: string,
|
||||
): Promise<{ stdout: string; stderr: string }> => {
|
||||
if (serverId) {
|
||||
return execAsyncRemote(serverId, command);
|
||||
}
|
||||
return execAsync(command);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a direct SSH connection to a server.
|
||||
* Used for streaming binary data (tar pipes) that can't go through execAsyncRemote.
|
||||
*/
|
||||
const getSSHConnection = async (
|
||||
serverId: string,
|
||||
): Promise<{ conn: Client }> => {
|
||||
const server = await findServerById(serverId);
|
||||
if (!server.sshKeyId) {
|
||||
throw new Error(`No SSH key configured for server ${server.name}`);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new Client();
|
||||
conn
|
||||
.on("ready", () => {
|
||||
resolve({ conn });
|
||||
})
|
||||
.on("error", (err) => {
|
||||
reject(
|
||||
new Error(
|
||||
`SSH connection failed to ${server.name} (${server.ipAddress}): ${err.message}`,
|
||||
),
|
||||
);
|
||||
})
|
||||
.connect({
|
||||
host: server.ipAddress,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
privateKey: server.sshKey?.privateKey,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Pipe a tar stream from source SSH connection to target SSH connection.
|
||||
*/
|
||||
const pipeSSH = (
|
||||
sourceConn: Client,
|
||||
targetConn: Client,
|
||||
sourceCmd: string,
|
||||
targetCmd: string,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
sourceConn.exec(sourceCmd, (err, sourceStream) => {
|
||||
if (err) return reject(new Error(`Source exec failed: ${err.message}`));
|
||||
|
||||
targetConn.exec(targetCmd, (err2, targetStream) => {
|
||||
if (err2)
|
||||
return reject(new Error(`Target exec failed: ${err2.message}`));
|
||||
|
||||
let totalBytes = 0;
|
||||
|
||||
sourceStream.on("data", (chunk: Buffer) => {
|
||||
totalBytes += chunk.length;
|
||||
targetStream.write(chunk);
|
||||
});
|
||||
|
||||
sourceStream.on("end", () => {
|
||||
targetStream.end();
|
||||
});
|
||||
|
||||
targetStream.on("close", () => {
|
||||
onLog?.(
|
||||
`Transferred ${(totalBytes / 1024 / 1024).toFixed(2)} MB`,
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
|
||||
sourceStream.on("error", (e: Error) =>
|
||||
reject(new Error(`Source stream error: ${e.message}`)),
|
||||
);
|
||||
targetStream.on("error", (e: Error) =>
|
||||
reject(new Error(`Target stream error: ${e.message}`)),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Stream data from local tar command into a remote SSH command.
|
||||
*/
|
||||
const pipeLocalToRemote = (
|
||||
targetConn: Client,
|
||||
localCmd: string,
|
||||
localArgs: string[],
|
||||
remoteCmd: string,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const localProcess = spawn(localCmd, localArgs, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
targetConn.exec(remoteCmd, (err, targetStream) => {
|
||||
if (err) {
|
||||
localProcess.kill();
|
||||
return reject(new Error(`Remote exec failed: ${err.message}`));
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
|
||||
localProcess.stdout.on("data", (chunk: Buffer) => {
|
||||
totalBytes += chunk.length;
|
||||
targetStream.write(chunk);
|
||||
});
|
||||
|
||||
localProcess.stdout.on("end", () => {
|
||||
targetStream.end();
|
||||
});
|
||||
|
||||
targetStream.on("close", () => {
|
||||
onLog?.(
|
||||
`Transferred ${(totalBytes / 1024 / 1024).toFixed(2)} MB`,
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
|
||||
localProcess.on("error", (e) => reject(e));
|
||||
targetStream.on("error", (e: Error) => reject(e));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Stream data from a remote SSH command into a local tar command.
|
||||
*/
|
||||
const pipeRemoteToLocal = (
|
||||
sourceConn: Client,
|
||||
remoteCmd: string,
|
||||
localCmd: string,
|
||||
localArgs: string[],
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const localProcess = spawn(localCmd, localArgs, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
sourceConn.exec(remoteCmd, (err, sourceStream) => {
|
||||
if (err) {
|
||||
localProcess.kill();
|
||||
return reject(new Error(`Remote exec failed: ${err.message}`));
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
|
||||
sourceStream.on("data", (chunk: Buffer) => {
|
||||
totalBytes += chunk.length;
|
||||
localProcess.stdin.write(chunk);
|
||||
});
|
||||
|
||||
sourceStream.on("end", () => {
|
||||
localProcess.stdin.end();
|
||||
});
|
||||
|
||||
localProcess.on("close", (code: number) => {
|
||||
onLog?.(
|
||||
`Transferred ${(totalBytes / 1024 / 1024).toFixed(2)} MB`,
|
||||
);
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Local process exited with code ${code}`));
|
||||
});
|
||||
|
||||
sourceStream.on("error", (e: Error) => reject(e));
|
||||
localProcess.on("error", (e) => reject(e));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const syncDirectory = async (
|
||||
sourceServerId: string | null,
|
||||
targetServerId: string,
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
onLog?.(`Syncing directory: ${sourcePath} → ${targetPath}`);
|
||||
|
||||
// Ensure target directory exists
|
||||
await execOnServer(targetServerId, `mkdir -p "${targetPath}"`);
|
||||
|
||||
if (sourceServerId && targetServerId) {
|
||||
// Remote → Remote: pipe tar directly between SSH connections
|
||||
onLog?.("Using direct SSH pipe for remote-to-remote transfer...");
|
||||
const [source, target] = await Promise.all([
|
||||
getSSHConnection(sourceServerId),
|
||||
getSSHConnection(targetServerId),
|
||||
]);
|
||||
try {
|
||||
await pipeSSH(
|
||||
source.conn,
|
||||
target.conn,
|
||||
`tar czf - -C "${sourcePath}" . 2>/dev/null`,
|
||||
`tar xzf - -C "${targetPath}"`,
|
||||
onLog,
|
||||
);
|
||||
} finally {
|
||||
source.conn.end();
|
||||
target.conn.end();
|
||||
}
|
||||
} else if (!sourceServerId && targetServerId) {
|
||||
// Local → Remote
|
||||
onLog?.("Transferring from local to remote...");
|
||||
const { conn } = await getSSHConnection(targetServerId);
|
||||
try {
|
||||
await pipeLocalToRemote(
|
||||
conn,
|
||||
"tar",
|
||||
["czf", "-", "-C", sourcePath, "."],
|
||||
`tar xzf - -C "${targetPath}"`,
|
||||
onLog,
|
||||
);
|
||||
} finally {
|
||||
conn.end();
|
||||
}
|
||||
} else if (sourceServerId && !targetServerId) {
|
||||
// Remote → Local
|
||||
onLog?.("Transferring from remote to local...");
|
||||
await execAsync(`mkdir -p "${targetPath}"`);
|
||||
const { conn } = await getSSHConnection(sourceServerId);
|
||||
try {
|
||||
await pipeRemoteToLocal(
|
||||
conn,
|
||||
`tar czf - -C "${sourcePath}" . 2>/dev/null`,
|
||||
"tar",
|
||||
["xzf", "-", "-C", targetPath],
|
||||
onLog,
|
||||
);
|
||||
} finally {
|
||||
conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
onLog?.(`Directory synced successfully: ${targetPath}`);
|
||||
};
|
||||
|
||||
export const syncDockerVolume = async (
|
||||
sourceServerId: string | null,
|
||||
targetServerId: string,
|
||||
volumeName: string,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
onLog?.(`Syncing Docker volume: ${volumeName}`);
|
||||
|
||||
// Ensure volume exists on target
|
||||
await execOnServer(
|
||||
targetServerId,
|
||||
`docker volume inspect "${volumeName}" > /dev/null 2>&1 || docker volume create "${volumeName}"`,
|
||||
);
|
||||
|
||||
const srcTarCmd = `docker run --rm -v "${volumeName}":/volume:ro alpine tar czf - -C /volume . 2>/dev/null`;
|
||||
const dstTarCmd = `docker run --rm -i -v "${volumeName}":/volume alpine tar xzf - -C /volume`;
|
||||
|
||||
if (sourceServerId && targetServerId) {
|
||||
// Remote → Remote
|
||||
onLog?.("Using direct SSH pipe for volume transfer...");
|
||||
const [source, target] = await Promise.all([
|
||||
getSSHConnection(sourceServerId),
|
||||
getSSHConnection(targetServerId),
|
||||
]);
|
||||
try {
|
||||
await pipeSSH(source.conn, target.conn, srcTarCmd, dstTarCmd, onLog);
|
||||
} finally {
|
||||
source.conn.end();
|
||||
target.conn.end();
|
||||
}
|
||||
} else if (!sourceServerId && targetServerId) {
|
||||
// Local → Remote
|
||||
onLog?.("Transferring volume from local to remote...");
|
||||
const { conn } = await getSSHConnection(targetServerId);
|
||||
try {
|
||||
await pipeLocalToRemote(
|
||||
conn,
|
||||
"docker",
|
||||
[
|
||||
"run", "--rm",
|
||||
"-v", `${volumeName}:/volume:ro`,
|
||||
"alpine", "tar", "czf", "-", "-C", "/volume", ".",
|
||||
],
|
||||
dstTarCmd,
|
||||
onLog,
|
||||
);
|
||||
} finally {
|
||||
conn.end();
|
||||
}
|
||||
} else if (sourceServerId && !targetServerId) {
|
||||
// Remote → Local
|
||||
onLog?.("Transferring volume from remote to local...");
|
||||
const { conn } = await getSSHConnection(sourceServerId);
|
||||
try {
|
||||
await pipeRemoteToLocal(
|
||||
conn,
|
||||
srcTarCmd,
|
||||
"docker",
|
||||
[
|
||||
"run", "--rm", "-i",
|
||||
"-v", `${volumeName}:/volume`,
|
||||
"alpine", "tar", "xzf", "-", "-C", "/volume",
|
||||
],
|
||||
onLog,
|
||||
);
|
||||
} finally {
|
||||
conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
onLog?.(`Volume synced successfully: ${volumeName}`);
|
||||
};
|
||||
|
||||
export const syncMount = async (
|
||||
sourceServerId: string | null,
|
||||
targetServerId: string,
|
||||
mount: MountTransferConfig,
|
||||
_decisions: Record<string, ConflictDecision>,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
if (mount.type === "volume" && mount.volumeName) {
|
||||
await syncDockerVolume(
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
mount.volumeName,
|
||||
onLog,
|
||||
);
|
||||
} else if (mount.type === "bind" && mount.hostPath) {
|
||||
await syncDirectory(
|
||||
sourceServerId,
|
||||
targetServerId,
|
||||
mount.hostPath,
|
||||
mount.hostPath,
|
||||
onLog,
|
||||
);
|
||||
} else if (mount.type === "file" && mount.content) {
|
||||
onLog?.("File mount will be recreated from database content during deploy");
|
||||
}
|
||||
};
|
||||
|
||||
export const syncTraefikConfig = async (
|
||||
sourceServerId: string | null,
|
||||
targetServerId: string,
|
||||
appName: string,
|
||||
onLog?: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
onLog?.(`Syncing Traefik config for: ${appName}`);
|
||||
|
||||
const configPath = "/etc/dokploy/traefik/dynamic";
|
||||
const configFile = `${configPath}/${appName}.yml`;
|
||||
|
||||
let configContent: string;
|
||||
try {
|
||||
const { stdout } = await execOnServer(
|
||||
sourceServerId,
|
||||
`cat "${configFile}" 2>/dev/null`,
|
||||
);
|
||||
configContent = stdout;
|
||||
} catch {
|
||||
onLog?.("No Traefik config found on source, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!configContent.trim()) {
|
||||
onLog?.("Empty Traefik config on source, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
await execOnServer(targetServerId, `mkdir -p "${configPath}"`);
|
||||
|
||||
const b64 = Buffer.from(configContent).toString("base64");
|
||||
await execOnServer(
|
||||
targetServerId,
|
||||
`echo "${b64}" | base64 -d > "${configFile}"`,
|
||||
);
|
||||
|
||||
onLog?.("Traefik config synced successfully");
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
export type ServiceType =
|
||||
| "application"
|
||||
| "compose"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "redis";
|
||||
|
||||
export interface FileInfo {
|
||||
path: string;
|
||||
size: number;
|
||||
modifiedAt: number;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
export type ConflictStatus =
|
||||
| "missing_target"
|
||||
| "newer_source"
|
||||
| "newer_target"
|
||||
| "conflict"
|
||||
| "match";
|
||||
|
||||
export interface FileConflict {
|
||||
path: string;
|
||||
status: ConflictStatus;
|
||||
sourceFile: FileInfo;
|
||||
targetFile?: FileInfo;
|
||||
}
|
||||
|
||||
export interface MountTransferConfig {
|
||||
mountId: string;
|
||||
type: "bind" | "volume" | "file";
|
||||
hostPath?: string | null;
|
||||
volumeName?: string | null;
|
||||
mountPath: string;
|
||||
content?: string | null;
|
||||
filePath?: string | null;
|
||||
}
|
||||
|
||||
export interface TransferScanResult {
|
||||
serviceDirectory: {
|
||||
files: FileConflict[];
|
||||
totalSize: number;
|
||||
};
|
||||
traefikConfig: {
|
||||
exists: boolean;
|
||||
hasConflict: boolean;
|
||||
};
|
||||
mounts: Array<{
|
||||
mount: MountTransferConfig;
|
||||
files: FileConflict[];
|
||||
totalSize: number;
|
||||
}>;
|
||||
totalTransferSize: number;
|
||||
totalFiles: number;
|
||||
conflicts: FileConflict[];
|
||||
}
|
||||
|
||||
export type ConflictDecision = "skip" | "overwrite";
|
||||
|
||||
export interface TransferProgress {
|
||||
phase:
|
||||
| "preparing"
|
||||
| "syncing_directory"
|
||||
| "syncing_traefik"
|
||||
| "syncing_mounts"
|
||||
| "updating_database"
|
||||
| "completed"
|
||||
| "failed";
|
||||
currentFile?: string;
|
||||
processedFiles: number;
|
||||
totalFiles: number;
|
||||
transferredBytes: number;
|
||||
totalBytes: number;
|
||||
percentage: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface TransferOptions {
|
||||
serviceId: string;
|
||||
serviceType: ServiceType;
|
||||
appName: string;
|
||||
sourceServerId: string | null;
|
||||
targetServerId: string;
|
||||
}
|
||||
|
||||
export interface TransferResult {
|
||||
success: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
@@ -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
30
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user