Merge branch 'canary' into feat/introduce-monitoring-self-hosted-pay

This commit is contained in:
Mauricio Siu
2025-03-18 21:51:09 -06:00
18 changed files with 501 additions and 356 deletions

View File

@@ -40,7 +40,7 @@ interface Props {
}
const AddRedirectchema = z.object({
replicas: z.number(),
replicas: z.number().min(1, "Replicas must be at least 1"),
registryId: z.string(),
});
@@ -130,9 +130,11 @@ export const ShowClusterSettings = ({ applicationId }: Props) => {
placeholder="1"
{...field}
onChange={(e) => {
field.onChange(Number(e.target.value));
const value = e.target.value;
field.onChange(value === "" ? 0 : Number(value));
}}
type="number"
value={field.value || ""}
/>
</FormControl>

View File

@@ -42,6 +42,7 @@ import { domain } from "@/server/db/validations/domain";
import { zodResolver } from "@hookform/resolvers/zod";
import { Dices } from "lucide-react";
import type z from "zod";
import Link from "next/link";
type Domain = z.infer<typeof domain>;
@@ -83,6 +84,13 @@ export const AddDomain = ({
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
api.domain.generateDomain.useMutation();
const { data: canGenerateTraefikMeDomains } =
api.domain.canGenerateTraefikMeDomains.useQuery({
serverId: application?.serverId || "",
});
console.log("canGenerateTraefikMeDomains", canGenerateTraefikMeDomains);
const form = useForm<Domain>({
resolver: zodResolver(domain),
defaultValues: {
@@ -186,6 +194,21 @@ export const AddDomain = ({
name="host"
render={({ field }) => (
<FormItem>
{!canGenerateTraefikMeDomains &&
field.value.includes("traefik.me") && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link
href="/dashboard/settings/server"
className="text-primary"
>
{application?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to make your traefik.me domain work.
</AlertBlock>
)}
<FormLabel>Host</FormLabel>
<div className="flex gap-2">
<FormControl>

View File

@@ -42,6 +42,7 @@ import { domainCompose } from "@/server/db/validations/domain";
import { zodResolver } from "@hookform/resolvers/zod";
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import type z from "zod";
import Link from "next/link";
type Domain = z.infer<typeof domainCompose>;
@@ -102,6 +103,11 @@ export const AddDomainCompose = ({
? api.domain.update.useMutation()
: api.domain.create.useMutation();
const { data: canGenerateTraefikMeDomains } =
api.domain.canGenerateTraefikMeDomains.useQuery({
serverId: compose?.serverId || "",
});
const form = useForm<Domain>({
resolver: zodResolver(domainCompose),
defaultValues: {
@@ -313,6 +319,21 @@ export const AddDomainCompose = ({
name="host"
render={({ field }) => (
<FormItem>
{!canGenerateTraefikMeDomains &&
field.value.includes("traefik.me") && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link
href="/dashboard/settings/server"
className="text-primary"
>
{compose?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to make your traefik.me domain work.
</AlertBlock>
)}
<FormLabel>Host</FormLabel>
<div className="flex gap-2">
<FormControl>

View File

@@ -48,6 +48,7 @@ import { toast } from "sonner";
interface Props {
databaseId: string;
databaseType: Exclude<ServiceType, "application" | "redis">;
serverId: string | null;
}
const RestoreBackupSchema = z.object({
@@ -76,7 +77,11 @@ const RestoreBackupSchema = z.object({
type RestoreBackup = z.infer<typeof RestoreBackupSchema>;
export const RestoreBackup = ({ databaseId, databaseType }: Props) => {
export const RestoreBackup = ({
databaseId,
databaseType,
serverId,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
@@ -101,6 +106,7 @@ export const RestoreBackup = ({ databaseId, databaseType }: Props) => {
{
destinationId: destionationId,
search,
serverId: serverId ?? "",
},
{
enabled: isOpen && !!destionationId,
@@ -304,7 +310,9 @@ export const RestoreBackup = ({ databaseId, databaseType }: Props) => {
form.setValue("backupFile", file);
}}
>
{file}
<div className="flex w-full justify-between">
<span>{file}</span>
</div>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -74,7 +74,11 @@ export const ShowBackups = ({ id, type }: Props) => {
{postgres && postgres?.backups?.length > 0 && (
<div className="flex flex-col lg:flex-row gap-4 w-full lg:w-auto">
<AddBackup databaseId={id} databaseType={type} refetch={refetch} />
<RestoreBackup databaseId={id} databaseType={type} />
<RestoreBackup
databaseId={id}
databaseType={type}
serverId={postgres.serverId}
/>
</div>
)}
</CardHeader>
@@ -108,7 +112,11 @@ export const ShowBackups = ({ id, type }: Props) => {
databaseType={type}
refetch={refetch}
/>
<RestoreBackup databaseId={id} databaseType={type} />
<RestoreBackup
databaseId={id}
databaseType={type}
serverId={postgres.serverId}
/>
</div>
</div>
) : (

View File

@@ -24,7 +24,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
{
mongoId,
},
{ enabled: !!mongoId }
{ enabled: !!mongoId },
);
const { mutateAsync: reload, isLoading: isReloading } =
@@ -61,7 +61,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
console.error("Deployment logs error:", error);
setIsDeploying(false);
},
}
},
);
return (
<>
@@ -82,16 +82,17 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
refetch();
}}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</Button>
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
@@ -99,6 +100,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Reload Mongo"
@@ -118,16 +120,17 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
});
}}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</Button>
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
@@ -135,6 +138,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
<DialogAction
@@ -154,16 +158,17 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
});
}}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</Button>
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
@@ -174,6 +179,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
@@ -192,16 +198,17 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
});
}}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</Button>
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
@@ -209,6 +216,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
</TooltipProvider>
@@ -216,15 +224,16 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
appName={data?.appName || ""}
serverId={data?.serverId || ""}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
className="flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-offset-2"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Terminal className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Terminal className="size-4 mr-1" />
Open Terminal
</Button>
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
@@ -232,6 +241,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DockerTerminalModal>
</CardContent>
</Card>

View File

@@ -56,10 +56,10 @@ export const AddNode = ({ serverId }: Props) => {
<TabsTrigger value="worker">Worker</TabsTrigger>
<TabsTrigger value="manager">Manager</TabsTrigger>
</TabsList>
<TabsContent value="worker" className="pt-4">
<TabsContent value="worker" className="pt-4 overflow-hidden">
<AddWorker serverId={serverId} />
</TabsContent>
<TabsContent value="manager" className="pt-4">
<TabsContent value="manager" className="pt-4 overflow-hidden">
<AddManager serverId={serverId} />
</TabsContent>
</Tabs>

View File

@@ -1,3 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CardContent } from "@/components/ui/card";
import {
DialogDescription,
@@ -6,7 +7,7 @@ import {
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import copy from "copy-to-clipboard";
import { CopyIcon } from "lucide-react";
import { CopyIcon, Loader2 } from "lucide-react";
import { toast } from "sonner";
interface Props {
@@ -14,18 +15,26 @@ interface Props {
}
export const AddManager = ({ serverId }: Props) => {
const { data } = api.cluster.addManager.useQuery({ serverId });
const { data, isLoading, error, isError } = api.cluster.addManager.useQuery({
serverId,
});
return (
<>
<div>
<CardContent className="sm:max-w-4xl max-h-screen overflow-y-auto flex flex-col gap-4 px-0">
<CardContent className="sm:max-w-4xl flex flex-col gap-4 px-0">
<DialogHeader>
<DialogTitle>Add a new manager</DialogTitle>
<DialogDescription>Add a new manager</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{isLoading ? (
<Loader2 className="w-full animate-spin text-muted-foreground" />
) : (
<>
<div className="flex flex-col gap-2.5 text-sm">
<span>1. Go to your new server and run the following command</span>
<span>
1. Go to your new server and run the following command
</span>
<span className="bg-muted rounded-lg p-2 flex justify-between">
curl https://get.docker.com | sh -s -- --version {data?.version}
<button
@@ -48,6 +57,7 @@ export const AddManager = ({ serverId }: Props) => {
2. Run the following command to add the node(manager) to your
cluster
</span>
<span className="bg-muted rounded-lg p-2 flex">
{data?.command}
<button
@@ -62,8 +72,9 @@ export const AddManager = ({ serverId }: Props) => {
</button>
</span>
</div>
</>
)}
</CardContent>
</div>
</>
);
};

View File

@@ -17,7 +17,7 @@ export const ShowNodesModal = ({ serverId }: Props) => {
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Nodes
Show Swarm Nodes
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-5xl overflow-y-auto max-h-screen ">

View File

@@ -1,3 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CardContent } from "@/components/ui/card";
import {
DialogDescription,
@@ -6,7 +7,7 @@ import {
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import copy from "copy-to-clipboard";
import { CopyIcon } from "lucide-react";
import { CopyIcon, Loader2 } from "lucide-react";
import { toast } from "sonner";
interface Props {
@@ -14,15 +15,21 @@ interface Props {
}
export const AddWorker = ({ serverId }: Props) => {
const { data } = api.cluster.addWorker.useQuery({ serverId });
const { data, isLoading, error, isError } = api.cluster.addWorker.useQuery({
serverId,
});
return (
<div>
<CardContent className="sm:max-w-4xl max-h-screen overflow-y-auto flex flex-col gap-4 px-0">
<CardContent className="sm:max-w-4xl flex flex-col gap-4 px-0">
<DialogHeader>
<DialogTitle>Add a new worker</DialogTitle>
<DialogDescription>Add a new worker</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{isLoading ? (
<Loader2 className="w-full animate-spin text-muted-foreground" />
) : (
<>
<div className="flex flex-col gap-2.5 text-sm">
<span>1. Go to your new server and run the following command</span>
<span className="bg-muted rounded-lg p-2 flex justify-between">
@@ -44,7 +51,8 @@ export const AddWorker = ({ serverId }: Props) => {
<div className="flex flex-col gap-2.5 text-sm">
<span>
2. Run the following command to add the node(worker) to your cluster
2. Run the following command to add the node(worker) to your
cluster
</span>
<span className="bg-muted rounded-lg p-2 flex">
@@ -61,7 +69,8 @@ export const AddWorker = ({ serverId }: Props) => {
</button>
</span>
</div>
</>
)}
</CardContent>
</div>
);
};

View File

@@ -663,13 +663,16 @@ export const HandleNotifications = ({ notificationId }: Props) => {
{...field}
onChange={(e) => {
const value = e.target.value;
if (value) {
if (value === "") {
field.onChange(undefined);
} else {
const port = Number.parseInt(value);
if (port > 0 && port < 65536) {
field.onChange(port);
}
}
}}
value={field.value || ""}
type="number"
/>
</FormControl>

View File

@@ -159,9 +159,11 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
<Input
type="number"
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
onChange={(e) => {
const value = e.target.value;
field.onChange(value === "" ? undefined : Number(value));
}}
value={field.value || ""}
className="w-full dark:bg-black"
placeholder="e.g. 8080"
/>
@@ -185,9 +187,11 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
<Input
type="number"
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
onChange={(e) => {
const value = e.target.value;
field.onChange(value === "" ? undefined : Number(value));
}}
value={field.value || ""}
className="w-full dark:bg-black"
placeholder="e.g. 80"
/>

View File

@@ -39,7 +39,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, InputProps>(
className={cn("text-left", className)}
ref={ref}
{...props}
value={props.value === undefined || props.value === "" ? "" : String(props.value)}
value={props.value === undefined ? undefined : String(props.value)}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
@@ -60,21 +60,6 @@ const NumberInput = React.forwardRef<HTMLInputElement, InputProps>(
}
}
}}
onBlur={(e) => {
// If input is empty, make 0 when focus is lost
if (e.target.value === "") {
const syntheticEvent = {
...e,
target: {
...e.target,
value: "0",
},
};
props.onChange?.(
syntheticEvent as unknown as React.ChangeEvent<HTMLInputElement>,
);
}
}}
/>
);
},

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.20.5",
"version": "v0.20.7",
"private": true,
"license": "Apache-2.0",
"type": "module",

View File

@@ -31,7 +31,10 @@ import {
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { getS3Credentials } from "@dokploy/server/utils/backups/utils";
import { findDestinationById } from "@dokploy/server/services/destination";
import {
@@ -229,6 +232,7 @@ export const backupRouter = createTRPCRouter({
z.object({
destinationId: z.string(),
search: z.string(),
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
@@ -250,7 +254,16 @@ export const backupRouter = createTRPCRouter({
const searchPath = baseDir ? `${bucketPath}/${baseDir}` : bucketPath;
const listCommand = `rclone lsf ${rcloneFlags.join(" ")} "${searchPath}" | head -n 100`;
const { stdout } = await execAsync(listCommand);
let stdout = "";
if (input.serverId) {
const result = await execAsyncRemote(listCommand, input.serverId);
stdout = result.stdout;
} else {
const result = await execAsync(listCommand);
stdout = result.stdout;
}
const files = stdout.split("\n").filter(Boolean);
const results = baseDir

View File

@@ -13,7 +13,9 @@ import {
findDomainById,
findDomainsByApplicationId,
findDomainsByComposeId,
findOrganizationById,
findPreviewDeploymentById,
findServerById,
generateTraefikMeDomain,
manageDomain,
removeDomain,
@@ -94,6 +96,19 @@ export const domainRouter = createTRPCRouter({
input.serverId,
);
}),
canGenerateTraefikMeDomains: protectedProcedure
.input(z.object({ serverId: z.string() }))
.query(async ({ input, ctx }) => {
const organization = await findOrganizationById(
ctx.session.activeOrganizationId,
);
if (input.serverId) {
const server = await findServerById(input.serverId);
return server.ipAddress;
}
return organization?.owner.serverIp;
}),
update: protectedProcedure
.input(apiUpdateDomain)

View File

@@ -8,6 +8,10 @@ import { db } from "../db";
import * as schema from "../db/schema";
import { sendEmail } from "../verification/send-verification-email";
import { IS_CLOUD } from "../constants";
import { getPublicIpWithFallback } from "../wss/utils";
import { updateUser } from "../services/user";
import { getUserByToken } from "../services/admin";
import { APIError } from "better-auth/api";
const { handler, api } = betterAuth({
database: drizzleAdapter(db, {
@@ -88,11 +92,40 @@ const { handler, api } = betterAuth({
databaseHooks: {
user: {
create: {
before: async (_user, context) => {
if (!IS_CLOUD) {
const xDokployToken =
context?.request?.headers?.get("x-dokploy-token");
if (xDokployToken) {
const user = await getUserByToken(xDokployToken);
if (!user) {
throw new APIError("BAD_REQUEST", {
message: "User not found",
});
}
} else {
const isAdminPresent = await db.query.member.findFirst({
where: eq(schema.member.role, "owner"),
});
if (isAdminPresent) {
throw new APIError("BAD_REQUEST", {
message: "Admin is already created",
});
}
}
}
},
after: async (user) => {
const isAdminPresent = await db.query.member.findFirst({
where: eq(schema.member.role, "owner"),
});
if (!IS_CLOUD) {
await updateUser(user.id, {
serverIp: await getPublicIpWithFallback(),
});
}
if (IS_CLOUD || !isAdminPresent) {
await db.transaction(async (tx) => {
const organization = await tx

View File

@@ -361,7 +361,7 @@ const installUtilities = () => `
alpine)
sed -i '/^#.*\/community/s/^#//' /etc/apk/repositories
apk update >/dev/null
apk add curl wget git jq openssl >/dev/null
apk add curl wget git jq openssl sudo unzip tar >/dev/null
;;
ubuntu | debian | raspbian)
DEBIAN_FRONTEND=noninteractive apt-get update -y >/dev/null