Merge branch 'canary' of github.com:ChristoferMendes/dokploy into feature/add-custom-webhook-notification-provider

This commit is contained in:
ChristoferMendes
2025-12-01 10:42:01 -03:00
66 changed files with 44095 additions and 1687 deletions

View File

@@ -1,6 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -28,6 +29,13 @@ interface Props {
const AddRedirectSchema = z.object({
command: z.string(),
args: z
.array(
z.object({
value: z.string().min(1, "Argument cannot be empty"),
}),
)
.optional(),
});
type AddCommand = z.infer<typeof AddRedirectSchema>;
@@ -47,22 +55,30 @@ export const AddCommand = ({ applicationId }: Props) => {
const form = useForm<AddCommand>({
defaultValues: {
command: "",
args: [],
},
resolver: zodResolver(AddRedirectSchema),
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "args",
});
useEffect(() => {
if (data?.command) {
if (data) {
form.reset({
command: data?.command || "",
args: data?.args?.map((arg) => ({ value: arg })) || [],
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
}, [data, form]);
const onSubmit = async (data: AddCommand) => {
await mutateAsync({
applicationId,
command: data?.command,
args: data?.args?.map((arg) => arg.value).filter(Boolean),
})
.then(async () => {
toast.success("Command Updated");
@@ -100,13 +116,65 @@ export const AddCommand = ({ applicationId }: Props) => {
<FormItem>
<FormLabel>Command</FormLabel>
<FormControl>
<Input placeholder="Custom command" {...field} />
<Input placeholder="/bin/sh" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>Arguments (Args)</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ value: "" })}
>
<Plus className="h-4 w-4 mr-1" />
Add Argument
</Button>
</div>
{fields.length === 0 && (
<p className="text-sm text-muted-foreground">
No arguments added yet. Click "Add Argument" to add one.
</p>
)}
{fields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`args.${index}.value`}
render={({ field }) => (
<FormItem>
<div className="flex gap-2">
<FormControl>
<Input
placeholder={
index === 0 ? "-c" : "echo Hello World"
}
{...field}
/>
</FormControl>
<Button
type="button"
variant="destructive"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
</div>
<div className="flex justify-end">
<Button isLoading={isLoading} type="submit" className="w-fit">

View File

@@ -0,0 +1,248 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Server } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
interface Props {
applicationId: string;
}
const schema = z.object({
buildServerId: z.string().min(1, "Build server is required"),
buildRegistryId: z.string().min(1, "Build registry is required"),
});
type Schema = z.infer<typeof schema>;
export const ShowBuildServer = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery(
{ applicationId },
{ enabled: !!applicationId },
);
const { data: buildServers } = api.server.buildServers.useQuery();
const { data: registries } = api.registry.all.useQuery();
const { mutateAsync, isLoading } = api.application.update.useMutation();
const form = useForm<Schema>({
defaultValues: {
buildServerId: data?.buildServerId || "",
buildRegistryId: data?.buildRegistryId || "",
},
resolver: zodResolver(schema),
});
useEffect(() => {
if (data) {
form.reset({
buildServerId: data?.buildServerId || "",
buildRegistryId: data?.buildRegistryId || "",
});
}
}, [form, form.reset, data]);
const onSubmit = async (formData: Schema) => {
await mutateAsync({
applicationId,
buildServerId:
formData?.buildServerId === "none" || !formData?.buildServerId
? null
: formData?.buildServerId,
buildRegistryId:
formData?.buildRegistryId === "none" || !formData?.buildRegistryId
? null
: formData?.buildRegistryId,
})
.then(async () => {
toast.success("Build Server Settings Updated");
await refetch();
})
.catch(() => {
toast.error("Error updating build server settings");
});
};
return (
<Card className="bg-background">
<CardHeader>
<div className="flex flex-row items-center gap-2">
<Server className="size-6 text-muted-foreground" />
<div>
<CardTitle className="text-xl">Build Server</CardTitle>
<CardDescription>
Configure a dedicated server for building your application.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info">
Build servers offload the build process from your deployment servers.
Select a build server and registry to use for building your
application.
</AlertBlock>
<AlertBlock type="info">
📊 <strong>Important:</strong> Once the build finishes, you'll need to
wait a few seconds for the deployment server to download the image.
These download logs will <strong>NOT</strong> appear in the build
deployment logs. Check the <strong>Logs</strong> tab to see when the
container starts running.
</AlertBlock>
{!registries || registries.length === 0 ? (
<AlertBlock type="warning">
You need to add at least one registry to use build servers. Please
go to{" "}
<Link
href="/dashboard/settings/registry"
className="text-primary underline"
>
Settings
</Link>{" "}
to add a registry.
</AlertBlock>
) : null}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="buildServerId"
render={({ field }) => (
<FormItem>
<FormLabel>Build Server</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || "none"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a build server" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectGroup>
<SelectItem value="none">
<span className="flex items-center gap-2">
<span>None</span>
</span>
</SelectItem>
{buildServers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
<span className="flex items-center gap-2 justify-between w-full">
<span>{server.name}</span>
<span className="text-muted-foreground text-xs">
{server.ipAddress}
</span>
</span>
</SelectItem>
))}
<SelectLabel>
Build Servers ({buildServers?.length || 0})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
Select a build server to handle the build process for this
application.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="buildRegistryId"
render={({ field }) => (
<FormItem>
<FormLabel>Build Registry</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || "none"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a registry" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectGroup>
<SelectItem value="none">
<span className="flex items-center gap-2">
<span>None</span>
</span>
</SelectItem>
{registries?.map((registry) => (
<SelectItem
key={registry.registryId}
value={registry.registryId}
>
{registry.registryName}
</SelectItem>
))}
<SelectLabel>
Registries ({registries?.length || 0})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
Select a registry to store the built images from the build
server.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -407,7 +407,7 @@ export const ShowDeployments = ({
</div>
)}
<ShowDeployment
serverId={serverId}
serverId={activeLog?.buildServerId || serverId}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog?.logPath || ""}

View File

@@ -46,7 +46,13 @@ export type CacheType = "fetch" | "cache";
export const domain = z
.object({
host: z.string().min(1, { message: "Add a hostname" }),
host: z
.string()
.min(1, { message: "Add a hostname" })
.refine((val) => val === val.trim(), {
message: "Domain name cannot have leading or trailing spaces",
})
.transform((val) => val.trim()),
path: z.string().min(1).optional(),
internalPath: z.string().optional(),
stripPath: z.boolean().optional(),
@@ -299,6 +305,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{type === "compose" && (
<AlertBlock type="info" className="mb-4">
Whenever you make changes to domains, remember to redeploy your
compose to apply the changes.
</AlertBlock>
)}
<Form {...form}>
<form
id="hook-form"

View File

@@ -1,6 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -20,6 +21,13 @@ import type { ServiceType } from "../../application/advanced/show-resources";
const addDockerImage = z.object({
dockerImage: z.string().min(1, "Docker image is required"),
command: z.string(),
args: z
.array(
z.object({
value: z.string().min(1, "Argument cannot be empty"),
}),
)
.optional(),
});
interface Props {
@@ -61,18 +69,25 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
defaultValues: {
dockerImage: "",
command: "",
args: [],
},
resolver: zodResolver(addDockerImage),
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "args",
});
useEffect(() => {
if (data) {
form.reset({
dockerImage: data.dockerImage,
command: data.command || "",
args: data.args?.map((arg) => ({ value: arg })) || [],
});
}
}, [data, form, form.reset]);
}, [data, form]);
const onSubmit = async (formData: AddDockerImage) => {
await mutateAsync({
@@ -83,6 +98,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
mariadbId: id || "",
dockerImage: formData?.dockerImage,
command: formData?.command,
args: formData?.args?.map((arg) => arg.value).filter(Boolean),
})
.then(async () => {
toast.success("Custom Command Updated");
@@ -128,13 +144,68 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
<FormItem>
<FormLabel>Command</FormLabel>
<FormControl>
<Input placeholder="Custom command" {...field} />
<Input placeholder="/bin/sh" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>Arguments (Args)</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ value: "" })}
>
<Plus className="h-4 w-4 mr-1" />
Add Argument
</Button>
</div>
{fields.length === 0 && (
<p className="text-sm text-muted-foreground">
No arguments added yet. Click "Add Argument" to add one.
</p>
)}
{fields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`args.${index}.value`}
render={({ field }) => (
<FormItem>
<div className="flex gap-2">
<FormControl>
<Input
placeholder={
index === 0
? "-c"
: "redis-server --port 6379"
}
{...field}
/>
</FormControl>
<Button
type="button"
variant="destructive"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
<div className="flex w-full justify-end">
<Button isLoading={form.formState.isSubmitting} type="submit">
Save

View File

@@ -138,7 +138,7 @@ export const ShowProjects = () => {
list={[{ name: "Projects", href: "/dashboard/projects" }]}
/>
{!isCloud && (
<div className="absolute top-5 right-5">
<div className="absolute top-4 right-4">
<TimeBadge />
</div>
)}

View File

@@ -49,51 +49,65 @@ export const RequestDistributionChart = ({
);
return (
<ResponsiveContainer width="100%" height={200}>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={stats || []}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) =>
new Date(value).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
}
/>
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
labelFormatter={(value) =>
new Date(value).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
/>
<Area
dataKey="count"
type="natural"
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
/>
</AreaChart>
</ChartContainer>
</ResponsiveContainer>
<div className="w-full h-[200px] overflow-hidden">
<ResponsiveContainer
width="100%"
height="100%"
className="overflow-hidden"
>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={stats || []}
margin={{
top: 10,
left: 12,
right: 12,
bottom: 0,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) =>
new Date(value).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
allowDataOverflow={false}
domain={[0, "auto"]}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
labelFormatter={(value) =>
new Date(value).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
/>
<Area
dataKey="count"
type="monotone"
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
/>
</AreaChart>
</ChartContainer>
</ResponsiveContainer>
</div>
);
};

View File

@@ -51,13 +51,19 @@ export const ShowRequests = () => {
const { mutateAsync: updateLogCleanup } =
api.settings.updateLogCleanup.useMutation();
const [cronExpression, setCronExpression] = useState<string | null>(null);
// Set default date range to last 3 days
const getDefaultDateRange = () => {
const to = new Date();
const from = new Date();
from.setDate(from.getDate() - 3);
return { from, to };
};
const [dateRange, setDateRange] = useState<{
from: Date | undefined;
to: Date | undefined;
}>({
from: undefined,
to: undefined,
});
}>(getDefaultDateRange());
useEffect(() => {
if (logCleanupStatus) {
@@ -169,17 +175,13 @@ export const ShowRequests = () => {
{isActive ? (
<>
<div className="flex justify-end mb-4 gap-2">
{(dateRange.from || dateRange.to) && (
<Button
variant="outline"
onClick={() =>
setDateRange({ from: undefined, to: undefined })
}
className="px-3"
>
Clear dates
</Button>
)}
<Button
variant="outline"
onClick={() => setDateRange(getDefaultDateRange())}
className="px-3"
>
Reset to Last 3 Days
</Button>
<Popover>
<PopoverTrigger asChild>
<Button

View File

@@ -1,12 +1,19 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, 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";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
@@ -26,13 +33,12 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
const Schema = z.object({
@@ -53,6 +59,8 @@ export const HandleAi = ({ aiId }: Props) => {
const utils = api.useUtils();
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [modelPopoverOpen, setModelPopoverOpen] = useState(false);
const [modelSearch, setModelSearch] = useState("");
const { data, refetch } = api.ai.one.useQuery(
{
aiId: aiId || "",
@@ -77,13 +85,17 @@ export const HandleAi = ({ aiId }: Props) => {
});
useEffect(() => {
form.reset({
name: data?.name ?? "",
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
apiKey: data?.apiKey ?? "",
model: data?.model ?? "",
isEnabled: data?.isEnabled ?? true,
});
if (data) {
form.reset({
name: data?.name ?? "",
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
apiKey: data?.apiKey ?? "",
model: data?.model ?? "",
isEnabled: data?.isEnabled ?? true,
});
}
setModelSearch("");
setModelPopoverOpen(false);
}, [aiId, form, data]);
const apiUrl = form.watch("apiUrl");
@@ -104,14 +116,6 @@ export const HandleAi = ({ aiId }: Props) => {
},
);
useEffect(() => {
const apiUrl = form.watch("apiUrl");
const apiKey = form.watch("apiKey");
if (apiUrl && apiKey) {
form.setValue("model", "");
}
}, [form.watch("apiUrl"), form.watch("apiKey")]);
const onSubmit = async (data: Schema) => {
try {
await mutateAsync({
@@ -131,7 +135,16 @@ export const HandleAi = ({ aiId }: Props) => {
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen) {
setModelSearch("");
setModelPopoverOpen(false);
}
}}
>
<DialogTrigger className="" asChild>
{aiId ? (
<Button
@@ -182,7 +195,17 @@ export const HandleAi = ({ aiId }: Props) => {
<FormItem>
<FormLabel>API URL</FormLabel>
<FormControl>
<Input placeholder="https://api.openai.com/v1" {...field} />
<Input
placeholder="https://api.openai.com/v1"
{...field}
onChange={(e) => {
field.onChange(e);
// Reset model when user changes API URL
if (form.getValues("model")) {
form.setValue("model", "");
}
}}
/>
</FormControl>
<FormDescription>
The base URL for your AI provider's API
@@ -205,6 +228,13 @@ export const HandleAi = ({ aiId }: Props) => {
placeholder="sk-..."
autoComplete="one-time-code"
{...field}
onChange={(e) => {
field.onChange(e);
// Reset model when user changes API Key
if (form.getValues("model")) {
form.setValue("model", "");
}
}}
/>
</FormControl>
<FormDescription>
@@ -232,30 +262,89 @@ export const HandleAi = ({ aiId }: Props) => {
<FormField
control={form.control}
name="model"
render={({ field }) => (
<FormItem>
<FormLabel>Model</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
</FormControl>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.id}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>Select an AI model to use</FormDescription>
<FormMessage />
</FormItem>
)}
render={({ field }) => {
const selectedModel = models.find(
(m) => m.id === field.value,
);
const filteredModels = models.filter((model) =>
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
);
// 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>
<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 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>
);
}}
/>
)}

View File

@@ -52,6 +52,7 @@ const Schema = z.object({
sshKeyId: z.string().min(1, {
message: "SSH Key is required",
}),
serverType: z.enum(["deploy", "build"]).default("deploy"),
});
type Schema = z.infer<typeof Schema>;
@@ -89,6 +90,7 @@ export const HandleServers = ({ serverId }: Props) => {
port: 22,
username: "root",
sshKeyId: "",
serverType: "deploy",
},
resolver: zodResolver(Schema),
});
@@ -101,6 +103,7 @@ export const HandleServers = ({ serverId }: Props) => {
port: data?.port || 22,
username: data?.username || "root",
sshKeyId: data?.sshKeyId || "",
serverType: data?.serverType || "deploy",
});
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
@@ -116,6 +119,7 @@ export const HandleServers = ({ serverId }: Props) => {
port: data.port || 22,
username: data.username || "root",
sshKeyId: data.sshKeyId || "",
serverType: data.serverType || "deploy",
serverId: serverId || "",
})
.then(async (_data) => {
@@ -266,6 +270,50 @@ export const HandleServers = ({ serverId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="serverType"
render={({ field }) => {
const serverTypeValue = form.watch("serverType");
return (
<FormItem>
<FormLabel>Server Type</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a server type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="deploy">Deploy Server</SelectItem>
<SelectItem value="build">Build Server</SelectItem>
<SelectLabel>Server Type</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
{serverTypeValue === "deploy" && (
<AlertBlock type="info" className="mt-2">
Deploy servers are used to run your applications,
databases, and services. They handle the deployment and
execution of your projects.
</AlertBlock>
)}
{serverTypeValue === "build" && (
<AlertBlock type="info" className="mt-2">
Build servers are dedicated to building your
applications. They handle the compilation and build
process, offloading this work from your deployment
servers. Build servers won't appear in deployment
options.
</AlertBlock>
)}
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="sshKeyId"

View File

@@ -51,6 +51,7 @@ export const SetupServer = ({ serverId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
const { data: isCloud } = api.settings.isCloud.useQuery();
const isBuildServer = server?.serverType === "build";
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isDeploying, setIsDeploying] = useState(false);
@@ -117,17 +118,26 @@ export const SetupServer = ({ serverId }: Props) => {
<TabsList
className={cn(
"grid w-[700px]",
isCloud ? "grid-cols-6" : "grid-cols-5",
isBuildServer
? "grid-cols-3"
: isCloud
? "grid-cols-6"
: "grid-cols-5",
)}
>
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="validate">Validate</TabsTrigger>
<TabsTrigger value="audit">Security</TabsTrigger>
{isCloud && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
{!isBuildServer && (
<>
<TabsTrigger value="audit">Security</TabsTrigger>
{isCloud && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)}
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
</>
)}
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
</TabsList>
<TabsContent
value="ssh-keys"
@@ -311,32 +321,36 @@ export const SetupServer = ({ serverId }: Props) => {
<ValidateServer serverId={serverId} />
</div>
</TabsContent>
<TabsContent
value="audit"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
<SecurityAudit serverId={serverId} />
</div>
</TabsContent>
<TabsContent
value="monitoring"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm pt-3">
<div className="rounded-xl bg-background shadow-md border">
<SetupMonitoring serverId={serverId} />
</div>
</div>
</TabsContent>
<TabsContent
value="gpu-setup"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
<GPUSupport serverId={serverId} />
</div>
</TabsContent>
{!isBuildServer && (
<>
<TabsContent
value="audit"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
<SecurityAudit serverId={serverId} />
</div>
</TabsContent>
<TabsContent
value="monitoring"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm pt-3">
<div className="rounded-xl bg-background shadow-md border">
<SetupMonitoring serverId={serverId} />
</div>
</div>
</TabsContent>
<TabsContent
value="gpu-setup"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
<GPUSupport serverId={serverId} />
</div>
</TabsContent>
</>
)}
</Tabs>
</div>
)}

View File

@@ -129,6 +129,9 @@ export const ShowServers = () => {
Status
</TableHead>
)}
<TableHead className="text-center">
Type
</TableHead>
<TableHead className="text-center">
IP Address
</TableHead>
@@ -153,6 +156,8 @@ export const ShowServers = () => {
{data?.map((server) => {
const canDelete = server.totalSum === 0;
const isActive = server.serverStatus === "active";
const isBuildServer =
server.serverType === "build";
return (
<TableRow key={server.serverId}>
<TableCell className="text-left">
@@ -171,6 +176,15 @@ export const ShowServers = () => {
</Badge>
</TableHead>
)}
<TableCell className="text-center">
<Badge
variant={
isBuildServer ? "secondary" : "default"
}
>
{server.serverType}
</Badge>
</TableCell>
<TableCell className="text-center">
<Badge>{server.ipAddress}</Badge>
</TableCell>
@@ -233,11 +247,12 @@ export const ShowServers = () => {
serverId={server.serverId}
/>
{server.sshKeyId && (
<ShowServerActions
serverId={server.serverId}
/>
)}
{server.sshKeyId &&
!isBuildServer && (
<ShowServerActions
serverId={server.serverId}
/>
)}
</>
)}
@@ -286,41 +301,43 @@ export const ShowServers = () => {
</DropdownMenuItem>
</DialogAction>
{isActive && server.sshKeyId && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>
Extra
</DropdownMenuLabel>
{isActive &&
server.sshKeyId &&
!isBuildServer && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>
Extra
</DropdownMenuLabel>
<ShowTraefikFileSystemModal
serverId={server.serverId}
/>
<ShowDockerContainersModal
serverId={server.serverId}
/>
{isCloud && (
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig?.server
?.token
}
<ShowTraefikFileSystemModal
serverId={server.serverId}
/>
)}
<ShowDockerContainersModal
serverId={server.serverId}
/>
{isCloud && (
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig
?.server?.token
}
/>
)}
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
<ShowNodesModal
serverId={server.serverId}
/>
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
<ShowNodesModal
serverId={server.serverId}
/>
<ShowSchedulesModal
serverId={server.serverId}
/>
</>
)}
<ShowSchedulesModal
serverId={server.serverId}
/>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>

View File

@@ -25,6 +25,13 @@ export const ValidateServer = ({ serverId }: Props) => {
enabled: !!serverId,
},
);
const { data: server } = api.server.one.useQuery(
{ serverId },
{
enabled: !!serverId,
},
);
const isBuildServer = server?.serverType === "build";
const _utils = api.useUtils();
return (
<CardContent className="p-0">
@@ -73,7 +80,9 @@ export const ValidateServer = ({ serverId }: Props) => {
<div className="border rounded-lg p-4">
<h3 className="text-lg font-semibold mb-1">Status</h3>
<p className="text-sm text-muted-foreground mb-4">
Shows the server configuration status
{isBuildServer
? "Shows the build server configuration status"
: "Shows the server configuration status"}
</p>
<div className="grid gap-2.5">
<StatusRow
@@ -85,15 +94,17 @@ export const ValidateServer = ({ serverId }: Props) => {
: undefined
}
/>
<StatusRow
label="RClone Installed"
isEnabled={data?.rclone?.enabled}
description={
data?.rclone?.enabled
? `Installed: ${data?.rclone?.version}`
: undefined
}
/>
{!isBuildServer && (
<StatusRow
label="RClone Installed"
isEnabled={data?.rclone?.enabled}
description={
data?.rclone?.enabled
? `Installed: ${data?.rclone?.version}`
: undefined
}
/>
)}
<StatusRow
label="Nixpacks Installed"
isEnabled={data?.nixpacks?.enabled}
@@ -113,23 +124,36 @@ export const ValidateServer = ({ serverId }: Props) => {
}
/>
<StatusRow
label="Docker Swarm Initialized"
isEnabled={data?.isSwarmInstalled}
label="Railpack Installed"
isEnabled={data?.railpack?.enabled}
description={
data?.isSwarmInstalled
? "Initialized"
: "Not Initialized"
}
/>
<StatusRow
label="Dokploy Network Created"
isEnabled={data?.isDokployNetworkInstalled}
description={
data?.isDokployNetworkInstalled
? "Created"
: "Not Created"
data?.railpack?.enabled
? `Installed: ${data?.railpack?.version}`
: undefined
}
/>
{!isBuildServer && (
<>
<StatusRow
label="Docker Swarm Initialized"
isEnabled={data?.isSwarmInstalled}
description={
data?.isSwarmInstalled
? "Initialized"
: "Not Initialized"
}
/>
<StatusRow
label="Dokploy Network Created"
isEnabled={data?.isDokployNetworkInstalled}
description={
data?.isDokployNetworkInstalled
? "Created"
: "Not Created"
}
/>
</>
)}
<StatusRow
label="Main Directory Created"
isEnabled={data?.isMainDirectoryInstalled}
@@ -139,15 +163,6 @@ export const ValidateServer = ({ serverId }: Props) => {
: "Not Created"
}
/>
<StatusRow
label="Railpack Installed"
isEnabled={data?.railpack?.enabled}
description={
data?.railpack?.enabled
? `Installed: ${data?.railpack?.version}`
: undefined
}
/>
</div>
</div>
</div>

View File

@@ -95,6 +95,7 @@ export const CreateServer = ({ stepper }: Props) => {
port: data.port || 22,
username: data.username || "root",
sshKeyId: data.sshKeyId || "",
serverType: "deploy",
})
.then(async (_data) => {
toast.success("Server Created");