Merge branch 'canary' into feature/node-cluster-logs

This commit is contained in:
Mauricio Siu
2024-12-25 02:15:14 -06:00
110 changed files with 8061 additions and 1354 deletions

View File

@@ -87,7 +87,7 @@ export const Login2FA = ({ authId }: Props) => {
</span> </span>
</div> </div>
)} )}
<CardTitle className="text-xl font-bold">2FA Setup</CardTitle> <CardTitle className="text-xl font-bold">2FA Login</CardTitle>
<FormField <FormField
control={form.control} control={form.control}

View File

@@ -1,3 +1,5 @@
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -5,11 +7,10 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { TerminalLine } from "../../docker/logs/terminal-line"; import { TerminalLine } from "../../docker/logs/terminal-line";
import { LogLine, parseLogs } from "../../docker/logs/utils"; import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { Badge } from "@/components/ui/badge";
import { Loader2 } from "lucide-react";
interface Props { interface Props {
logPath: string | null; logPath: string | null;
@@ -19,12 +20,12 @@ interface Props {
} }
export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => { export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
const [data, setData] = useState(""); const [data, setData] = useState("");
const [showExtraLogs, setShowExtraLogs] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]); const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
const [autoScroll, setAutoScroll] = useState(true); const [autoScroll, setAutoScroll] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => { const scrollToBottom = () => {
if (autoScroll && scrollRef.current) { if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
@@ -69,11 +70,26 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
}; };
}, [logPath, open]); }, [logPath, open]);
useEffect(() => { useEffect(() => {
const logs = parseLogs(data); const logs = parseLogs(data);
setFilteredLogs(logs); let filteredLogsResult = logs;
}, [data]); if (serverId) {
let hideSubsequentLogs = false;
filteredLogsResult = logs.filter((log) => {
if (
log.message.includes(
"===================================EXTRA LOGS============================================",
)
) {
hideSubsequentLogs = true;
return showExtraLogs;
}
return showExtraLogs ? true : !hideSubsequentLogs;
});
}
setFilteredLogs(filteredLogsResult);
}, [data, showExtraLogs]);
useEffect(() => { useEffect(() => {
scrollToBottom(); scrollToBottom();
@@ -83,7 +99,6 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
} }
}, [filteredLogs, autoScroll]); }, [filteredLogs, autoScroll]);
return ( return (
<Dialog <Dialog
open={open} open={open}
@@ -103,8 +118,31 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
<DialogContent className={"sm:max-w-5xl overflow-y-auto max-h-screen"}> <DialogContent className={"sm:max-w-5xl overflow-y-auto max-h-screen"}>
<DialogHeader> <DialogHeader>
<DialogTitle>Deployment</DialogTitle> <DialogTitle>Deployment</DialogTitle>
<DialogDescription> <DialogDescription className="flex items-center gap-2">
See all the details of this deployment | <Badge variant="blank" className="text-xs">{filteredLogs.length} lines</Badge> <span>
See all the details of this deployment |{" "}
<Badge variant="blank" className="text-xs">
{filteredLogs.length} lines
</Badge>
</span>
{serverId && (
<div className="flex items-center space-x-2">
<Checkbox
id="show-extra-logs"
checked={showExtraLogs}
onCheckedChange={(checked) =>
setShowExtraLogs(checked as boolean)
}
/>
<label
htmlFor="show-extra-logs"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Show Extra Logs
</label>
</div>
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -112,15 +150,13 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
ref={scrollRef} ref={scrollRef}
onScroll={handleScroll} onScroll={handleScroll}
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar" className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
> { >
filteredLogs.length > 0 ? filteredLogs.map((log: LogLine, index: number) => ( {" "}
<TerminalLine {filteredLogs.length > 0 ? (
key={index} filteredLogs.map((log: LogLine, index: number) => (
log={log} <TerminalLine key={index} log={log} noTimestamp />
noTimestamp ))
/> ) : (
)) :
(
<div className="flex justify-center items-center h-full text-muted-foreground"> <div className="flex justify-center items-center h-full text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" /> <Loader2 className="h-6 w-6 animate-spin" />
</div> </div>

View File

@@ -264,21 +264,21 @@ export const AddDomain = ({
name="certificateType" name="certificateType"
render={({ field }) => ( render={({ field }) => (
<FormItem className="col-span-2"> <FormItem className="col-span-2">
<FormLabel>Certificate</FormLabel> <FormLabel>Certificate Provider</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value || ""} defaultValue={field.value || ""}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a certificate" /> <SelectValue placeholder="Select a certificate provider" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="none">None</SelectItem> <SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}> <SelectItem value={"letsencrypt"}>
Letsencrypt (Default) Let's Encrypt
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -265,21 +265,21 @@ export const AddPreviewDomain = ({
name="certificateType" name="certificateType"
render={({ field }) => ( render={({ field }) => (
<FormItem className="col-span-2"> <FormItem className="col-span-2">
<FormLabel>Certificate</FormLabel> <FormLabel>Certificate Provider</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value || ""} defaultValue={field.value || ""}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a certificate" /> <SelectValue placeholder="Select a certificate provider" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="none">None</SelectItem> <SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}> <SelectItem value={"letsencrypt"}>
Letsencrypt (Default) Let's Encrypt
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -18,15 +18,26 @@ import { ShowDeployment } from "../deployments/show-deployment";
interface Props { interface Props {
deployments: RouterOutputs["deployment"]["all"]; deployments: RouterOutputs["deployment"]["all"];
serverId?: string; serverId?: string;
trigger?: React.ReactNode;
} }
export const ShowPreviewBuilds = ({ deployments, serverId }: Props) => { export const ShowPreviewBuilds = ({
deployments,
serverId,
trigger,
}: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null); const [activeLog, setActiveLog] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline">View Builds</Button> {trigger ? (
trigger
) : (
<Button className="sm:w-auto w-full" size="sm" variant="outline">
View Builds
</Button>
)}
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl"> <DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl">
<DialogHeader> <DialogHeader>

View File

@@ -1,5 +1,8 @@
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { DateTooltip } from "@/components/shared/date-tooltip"; import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip"; import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -8,30 +11,34 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch"; import { Input } from "@/components/ui/input";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Pencil, RocketIcon } from "lucide-react"; import {
import React, { useEffect, useState } from "react"; ExternalLink,
FileText,
GitPullRequest,
Layers,
PenSquare,
RocketIcon,
Trash2,
} from "lucide-react";
import React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ShowDeployment } from "../deployments/show-deployment";
import Link from "next/link";
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs"; import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
import { DialogAction } from "@/components/shared/dialog-action";
import { AddPreviewDomain } from "./add-preview-domain"; import { AddPreviewDomain } from "./add-preview-domain";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { ShowPreviewSettings } from "./show-preview-settings";
import { ShowPreviewBuilds } from "./show-preview-builds"; import { ShowPreviewBuilds } from "./show-preview-builds";
import { ShowPreviewSettings } from "./show-preview-settings";
interface Props { interface Props {
applicationId: string; applicationId: string;
} }
export const ShowPreviewDeployments = ({ applicationId }: Props) => { export const ShowPreviewDeployments = ({ applicationId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
const { data } = api.application.one.useQuery({ applicationId }); const { data } = api.application.one.useQuery({ applicationId });
const { mutateAsync: deletePreviewDeployment, isLoading } = const { mutateAsync: deletePreviewDeployment, isLoading } =
api.previewDeployment.delete.useMutation(); api.previewDeployment.delete.useMutation();
const { data: previewDeployments, refetch: refetchPreviewDeployments } = const { data: previewDeployments, refetch: refetchPreviewDeployments } =
api.previewDeployment.all.useQuery( api.previewDeployment.all.useQuery(
{ applicationId }, { applicationId },
@@ -39,10 +46,19 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
enabled: !!applicationId, enabled: !!applicationId,
}, },
); );
// const [url, setUrl] = React.useState("");
// useEffect(() => { const handleDeletePreviewDeployment = async (previewDeploymentId: string) => {
// setUrl(document.location.origin); deletePreviewDeployment({
// }, []); previewDeploymentId: previewDeploymentId,
})
.then(() => {
refetchPreviewDeployments();
toast.success("Preview deployment deleted");
})
.catch((error) => {
toast.error(error.message);
});
};
return ( return (
<Card className="bg-background"> <Card className="bg-background">
@@ -65,7 +81,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
each pull request you create. each pull request you create.
</span> </span>
</div> </div>
{data?.previewDeployments?.length === 0 ? ( {!previewDeployments?.length ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10"> <div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<RocketIcon className="size-8 text-muted-foreground" /> <RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground"> <span className="text-base text-muted-foreground">
@@ -74,123 +90,134 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{previewDeployments?.map((previewDeployment) => { {previewDeployments?.map((deployment) => {
const { deployments, domain } = previewDeployment; const deploymentUrl = `${deployment.domain?.https ? "https" : "http"}://${deployment.domain?.host}${deployment.domain?.path || "/"}`;
const status = deployment.previewStatus;
return ( return (
<div <div
key={previewDeployment?.previewDeploymentId} key={deployment.previewDeploymentId}
className="flex flex-col justify-between rounded-lg border p-4 gap-2" className="group relative overflow-hidden border rounded-lg transition-colors"
> >
<div className="flex justify-between gap-2 max-sm:flex-wrap"> <div
<div className="flex flex-col gap-2"> className={`absolute left-0 top-0 w-1 h-full ${
{deployments?.length === 0 ? ( status === "done"
? "bg-green-500"
: status === "running"
? "bg-yellow-500"
: "bg-red-500"
}`}
/>
<div className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-start gap-3">
<GitPullRequest className="size-5 text-muted-foreground mt-1 flex-shrink-0" />
<div> <div>
<span className="text-sm text-muted-foreground"> <div className="font-medium text-sm">
No deployments found {deployment.pullRequestTitle}
</span>
</div> </div>
) : ( <div className="text-sm text-muted-foreground mt-1">
<div className="flex items-center gap-2"> {deployment.branch}
<span className="flex items-center gap-4 font-medium capitalize text-foreground"> </div>
{previewDeployment?.pullRequestTitle} </div>
</span> </div>
<Badge variant="outline" className="gap-2">
<StatusTooltip <StatusTooltip
status={previewDeployment.previewStatus} status={deployment.previewStatus}
className="size-2.5" className="size-2"
/> />
<DateTooltip date={deployment.createdAt} />
</Badge>
</div> </div>
)}
<div className="flex flex-col gap-1">
{previewDeployment?.pullRequestTitle && (
<div className="flex items-center gap-2">
<span className="break-all text-sm text-muted-foreground w-fit">
Title: {previewDeployment?.pullRequestTitle}
</span>
</div>
)}
{previewDeployment?.pullRequestURL && ( <div className="pl-8 space-y-3">
<div className="flex items-center gap-2"> <div className="relative flex-grow">
<GithubIcon /> <Input
<Link value={deploymentUrl}
target="_blank" readOnly
href={previewDeployment?.pullRequestURL} className="pr-8 text-sm text-blue-500 hover:text-blue-600 cursor-pointer"
className="break-all text-sm text-muted-foreground w-fit hover:underline hover:text-foreground" onClick={() =>
> window.open(deploymentUrl, "_blank")
Pull Request URL }
</Link> />
</div> <ExternalLink className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-gray-400" />
)} </div>
</div>
<div className="flex flex-col "> <div className="flex gap-2 opacity-80 group-hover:opacity-100 transition-opacity">
<span>Domain </span> <Button
<div className="flex flex-row items-center gap-4"> variant="outline"
<Link size="sm"
target="_blank" className="gap-2"
href={`http://${domain?.host}`} onClick={() =>
className="text-sm text-muted-foreground w-fit hover:underline hover:text-foreground" window.open(deployment.pullRequestURL, "_blank")
>
{domain?.host}
</Link>
<AddPreviewDomain
previewDeploymentId={
previewDeployment.previewDeploymentId
} }
domainId={domain?.domainId}
> >
<Button variant="outline" size="sm"> <GithubIcon className="size-4" />
<Pencil className="size-4 text-muted-foreground" /> Pull Request
</Button> </Button>
</AddPreviewDomain>
</div>
</div>
</div>
<div className="flex flex-col sm:items-end gap-2 max-sm:w-full">
{previewDeployment?.createdAt && (
<div className="text-sm capitalize text-muted-foreground">
<DateTooltip
date={previewDeployment?.createdAt}
/>
</div>
)}
<ShowPreviewBuilds
deployments={previewDeployment?.deployments || []}
serverId={data?.serverId || ""}
/>
<ShowModalLogs <ShowModalLogs
appName={previewDeployment.appName} appName={deployment.appName}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
> >
<Button variant="outline">View Logs</Button> <Button
variant="outline"
size="sm"
className="gap-2"
>
<FileText className="size-4" />
Logs
</Button>
</ShowModalLogs> </ShowModalLogs>
<ShowPreviewBuilds
deployments={deployment.deployments || []}
serverId={data?.serverId || ""}
trigger={
<Button
variant="outline"
size="sm"
className="gap-2"
>
<Layers className="size-4" />
Builds
</Button>
}
/>
<AddPreviewDomain
previewDeploymentId={`${deployment.previewDeploymentId}`}
domainId={deployment.domain?.domainId}
>
<Button
variant="ghost"
size="sm"
className="gap-2"
>
<PenSquare className="size-4" />
</Button>
</AddPreviewDomain>
<DialogAction <DialogAction
title="Delete Preview" title="Delete Preview"
description="Are you sure you want to delete this preview?" description="Are you sure you want to delete this preview?"
onClick={() => { onClick={() =>
deletePreviewDeployment({ handleDeletePreviewDeployment(
previewDeploymentId: deployment.previewDeploymentId,
previewDeployment.previewDeploymentId, )
}) }
.then(() => {
refetchPreviewDeployments();
toast.success("Preview deployment deleted");
})
.catch((error) => {
toast.error(error.message);
});
}}
> >
<Button variant="destructive" isLoading={isLoading}> <Button
Delete Preview variant="ghost"
size="sm"
isLoading={isLoading}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="size-4" />
</Button> </Button>
</DialogAction> </DialogAction>
</div> </div>
</div> </div>
</div> </div>
</div>
); );
})} })}
</div> </div>

View File

@@ -1,5 +1,3 @@
import { api } from "@/utils/api";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -20,12 +18,7 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input, NumberInput } from "@/components/ui/input"; import { Input, NumberInput } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Secrets } from "@/components/ui/secrets"; import { Secrets } from "@/components/ui/secrets";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -33,6 +26,14 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Settings2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const schema = z.object({ const schema = z.object({
env: z.string(), env: z.string(),
@@ -116,7 +117,10 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<div> <div>
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline">View Settings</Button> <Button variant="outline">
<Settings2 className="size-4" />
Configure
</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl w-full"> <DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl w-full">
<DialogHeader> <DialogHeader>
@@ -218,21 +222,21 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
name="previewCertificateType" name="previewCertificateType"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Certificate</FormLabel> <FormLabel>Certificate Provider</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value || ""} defaultValue={field.value || ""}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a certificate" /> <SelectValue placeholder="Select a certificate provider" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="none">None</SelectItem> <SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}> <SelectItem value={"letsencrypt"}>
Letsencrypt (Default) Let's Encrypt
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -1,3 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -91,7 +92,7 @@ export const AddCommandCompose = ({ composeId }: Props) => {
<div> <div>
<CardTitle className="text-xl">Run Command</CardTitle> <CardTitle className="text-xl">Run Command</CardTitle>
<CardDescription> <CardDescription>
Append a custom command to the compose file Override a custom command to the compose file
</CardDescription> </CardDescription>
</div> </div>
</CardHeader> </CardHeader>
@@ -101,6 +102,12 @@ export const AddCommandCompose = ({ composeId }: Props) => {
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4" className="grid w-full gap-4"
> >
<AlertBlock type="warning">
Modifying the default command may affect deployment stability,
impacting logs and monitoring. Proceed carefully and test
thoroughly. By default, the command starts with{" "}
<strong>docker</strong>.
</AlertBlock>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<FormField <FormField
control={form.control} control={form.control}

View File

@@ -1,5 +1,6 @@
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -12,6 +13,7 @@ import {
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -32,6 +34,7 @@ const deleteComposeSchema = z.object({
projectName: z.string().min(1, { projectName: z.string().min(1, {
message: "Compose name is required", message: "Compose name is required",
}), }),
deleteVolumes: z.boolean(),
}); });
type DeleteCompose = z.infer<typeof deleteComposeSchema>; type DeleteCompose = z.infer<typeof deleteComposeSchema>;
@@ -51,6 +54,7 @@ export const DeleteCompose = ({ composeId }: Props) => {
const form = useForm<DeleteCompose>({ const form = useForm<DeleteCompose>({
defaultValues: { defaultValues: {
projectName: "", projectName: "",
deleteVolumes: false,
}, },
resolver: zodResolver(deleteComposeSchema), resolver: zodResolver(deleteComposeSchema),
}); });
@@ -58,7 +62,8 @@ export const DeleteCompose = ({ composeId }: Props) => {
const onSubmit = async (formData: DeleteCompose) => { const onSubmit = async (formData: DeleteCompose) => {
const expectedName = `${data?.name}/${data?.appName}`; const expectedName = `${data?.name}/${data?.appName}`;
if (formData.projectName === expectedName) { if (formData.projectName === expectedName) {
await mutateAsync({ composeId }) const { deleteVolumes } = formData;
await mutateAsync({ composeId, deleteVolumes })
.then((result) => { .then((result) => {
push(`/dashboard/project/${result?.projectId}`); push(`/dashboard/project/${result?.projectId}`);
toast.success("Compose deleted successfully"); toast.success("Compose deleted successfully");
@@ -133,6 +138,27 @@ export const DeleteCompose = ({ composeId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="deleteVolumes"
render={({ field }) => (
<FormItem>
<div className="flex items-center">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="ml-2">
Delete volumes associated with this compose
</FormLabel>
</div>
<FormMessage />
</FormItem>
)}
/>
</form> </form>
</Form> </Form>
</div> </div>

View File

@@ -1,3 +1,5 @@
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -5,12 +7,10 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { TerminalLine } from "../../docker/logs/terminal-line"; import { TerminalLine } from "../../docker/logs/terminal-line";
import { LogLine, parseLogs } from "../../docker/logs/utils"; import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { Badge } from "@/components/ui/badge";
import { Loader2 } from "lucide-react";
interface Props { interface Props {
logPath: string | null; logPath: string | null;
@@ -26,6 +26,7 @@ export const ShowDeploymentCompose = ({
}: Props) => { }: Props) => {
const [data, setData] = useState(""); const [data, setData] = useState("");
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]); const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [showExtraLogs, setShowExtraLogs] = useState(false);
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
const [autoScroll, setAutoScroll] = useState(true); const [autoScroll, setAutoScroll] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@@ -44,7 +45,6 @@ export const ShowDeploymentCompose = ({
setAutoScroll(isAtBottom); setAutoScroll(isAtBottom);
}; };
useEffect(() => { useEffect(() => {
if (!open || !logPath) return; if (!open || !logPath) return;
@@ -76,11 +76,26 @@ export const ShowDeploymentCompose = ({
}; };
}, [logPath, open]); }, [logPath, open]);
useEffect(() => { useEffect(() => {
const logs = parseLogs(data); const logs = parseLogs(data);
setFilteredLogs(logs); let filteredLogsResult = logs;
}, [data]); if (serverId) {
let hideSubsequentLogs = false;
filteredLogsResult = logs.filter((log) => {
if (
log.message.includes(
"===================================EXTRA LOGS============================================",
)
) {
hideSubsequentLogs = true;
return showExtraLogs;
}
return showExtraLogs ? true : !hideSubsequentLogs;
});
}
setFilteredLogs(filteredLogsResult);
}, [data, showExtraLogs]);
useEffect(() => { useEffect(() => {
scrollToBottom(); scrollToBottom();
@@ -109,8 +124,30 @@ export const ShowDeploymentCompose = ({
<DialogContent className={"sm:max-w-5xl max-h-screen"}> <DialogContent className={"sm:max-w-5xl max-h-screen"}>
<DialogHeader> <DialogHeader>
<DialogTitle>Deployment</DialogTitle> <DialogTitle>Deployment</DialogTitle>
<DialogDescription> <DialogDescription className="flex items-center gap-2">
See all the details of this deployment | <Badge variant="blank" className="text-xs">{filteredLogs.length} lines</Badge> <span>
See all the details of this deployment |{" "}
<Badge variant="blank" className="text-xs">
{filteredLogs.length} lines
</Badge>
</span>
{serverId && (
<div className="flex items-center space-x-2">
<Checkbox
id="show-extra-logs"
checked={showExtraLogs}
onCheckedChange={(checked) =>
setShowExtraLogs(checked as boolean)
}
/>
<label
htmlFor="show-extra-logs"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Show Extra Logs
</label>
</div>
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -119,22 +156,15 @@ export const ShowDeploymentCompose = ({
onScroll={handleScroll} onScroll={handleScroll}
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar" className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
> >
{filteredLogs.length > 0 ? (
filteredLogs.map((log: LogLine, index: number) => (
{ <TerminalLine key={index} log={log} noTimestamp />
filteredLogs.length > 0 ? filteredLogs.map((log: LogLine, index: number) => ( ))
<TerminalLine ) : (
key={index}
log={log}
noTimestamp
/>
)) :
(
<div className="flex justify-center items-center h-full text-muted-foreground"> <div className="flex justify-center items-center h-full text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" /> <Loader2 className="h-6 w-6 animate-spin" />
</div> </div>
) )}
}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -400,21 +400,21 @@ export const AddDomainCompose = ({
name="certificateType" name="certificateType"
render={({ field }) => ( render={({ field }) => (
<FormItem className="col-span-2"> <FormItem className="col-span-2">
<FormLabel>Certificate</FormLabel> <FormLabel>Certificate Provider</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value || ""} defaultValue={field.value || ""}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a certificate" /> <SelectValue placeholder="Select a certificate provider" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="none">None</SelectItem> <SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}> <SelectItem value={"letsencrypt"}>
Letsencrypt (Default) Let's Encrypt
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -53,7 +53,7 @@ export const DeployCompose = ({ composeId }: Props) => {
}) })
.then(async () => { .then(async () => {
router.push( router.push(
`/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments` `/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments`,
); );
}) })
.catch(() => { .catch(() => {

View File

@@ -7,9 +7,10 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FancyAnsi } from "fancy-ansi";
import { escapeRegExp } from "lodash"; import { escapeRegExp } from "lodash";
import React from "react"; import React from "react";
import { type LogLine, getLogType, parseAnsi } from "./utils"; import { type LogLine, getLogType } from "./utils";
interface LogLineProps { interface LogLineProps {
log: LogLine; log: LogLine;
@@ -17,6 +18,8 @@ interface LogLineProps {
searchTerm?: string; searchTerm?: string;
} }
const fancyAnsi = new FancyAnsi();
export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) { export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
const { timestamp, message, rawTimestamp } = log; const { timestamp, message, rawTimestamp } = log;
const { type, variant, color } = getLogType(message); const { type, variant, color } = getLogType(message);
@@ -34,37 +37,42 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
const highlightMessage = (text: string, term: string) => { const highlightMessage = (text: string, term: string) => {
if (!term) { if (!term) {
const segments = parseAnsi(text); return (
return segments.map((segment, index) => ( <span
<span key={index} className={segment.className || undefined}> className="transition-colors"
{segment.text} dangerouslySetInnerHTML={{
</span> __html: fancyAnsi.toHtml(text),
)); }}
/>
);
} }
// For search, we need to handle both ANSI and search highlighting const htmlContent = fancyAnsi.toHtml(text);
const segments = parseAnsi(text); const modifiedContent = htmlContent.replace(
return segments.map((segment, index) => { /<span([^>]*)>([^<]*)<\/span>/g,
const parts = segment.text.split( (match, attrs, content) => {
new RegExp(`(${escapeRegExp(term)})`, "gi"), const searchRegex = new RegExp(`(${escapeRegExp(term)})`, "gi");
if (!content.match(searchRegex)) return match;
const segments = content.split(searchRegex);
const wrappedSegments = segments
.map((segment: string) =>
segment.toLowerCase() === term.toLowerCase()
? `<span${attrs} class="bg-yellow-200/50 dark:bg-yellow-900/50">${segment}</span>`
: segment,
)
.join("");
return `<span${attrs}>${wrappedSegments}</span>`;
},
); );
return ( return (
<span key={index} className={segment.className || undefined}>
{parts.map((part, partIndex) =>
part.toLowerCase() === term.toLowerCase() ? (
<span <span
key={partIndex} className="transition-colors"
className="bg-yellow-200 dark:bg-yellow-900" dangerouslySetInnerHTML={{ __html: modifiedContent }}
> />
{part}
</span>
) : (
part
),
)}
</span>
); );
});
}; };
const tooltip = (color: string, timestamp: string | null) => { const tooltip = (color: string, timestamp: string | null) => {

View File

@@ -12,47 +12,6 @@ interface LogStyle {
variant: LogVariant; variant: LogVariant;
color: string; color: string;
} }
interface AnsiSegment {
text: string;
className: string;
}
const ansiToTailwind: Record<number, string> = {
// Reset
0: "",
// Regular colors
30: "text-black dark:text-gray-900",
31: "text-red-600 dark:text-red-500",
32: "text-green-600 dark:text-green-500",
33: "text-yellow-600 dark:text-yellow-500",
34: "text-blue-600 dark:text-blue-500",
35: "text-purple-600 dark:text-purple-500",
36: "text-cyan-600 dark:text-cyan-500",
37: "text-gray-600 dark:text-gray-400",
// Bright colors
90: "text-gray-500 dark:text-gray-600",
91: "text-red-500 dark:text-red-600",
92: "text-green-500 dark:text-green-600",
93: "text-yellow-500 dark:text-yellow-600",
94: "text-blue-500 dark:text-blue-600",
95: "text-purple-500 dark:text-purple-600",
96: "text-cyan-500 dark:text-cyan-600",
97: "text-white dark:text-gray-300",
// Background colors
40: "bg-black",
41: "bg-red-600",
42: "bg-green-600",
43: "bg-yellow-600",
44: "bg-blue-600",
45: "bg-purple-600",
46: "bg-cyan-600",
47: "bg-white",
// Formatting
1: "font-bold",
2: "opacity-75",
3: "italic",
4: "underline",
};
const LOG_STYLES: Record<LogType, LogStyle> = { const LOG_STYLES: Record<LogType, LogStyle> = {
error: { error: {
@@ -191,56 +150,3 @@ export const getLogType = (message: string): LogStyle => {
return LOG_STYLES.info; return LOG_STYLES.info;
}; };
export function parseAnsi(text: string) {
const segments: { text: string; className: string }[] = [];
let currentIndex = 0;
let currentClasses: string[] = [];
while (currentIndex < text.length) {
const escStart = text.indexOf("\x1b[", currentIndex);
// No more escape sequences found
if (escStart === -1) {
if (currentIndex < text.length) {
segments.push({
text: text.slice(currentIndex),
className: currentClasses.join(" "),
});
}
break;
}
// Add text before escape sequence
if (escStart > currentIndex) {
segments.push({
text: text.slice(currentIndex, escStart),
className: currentClasses.join(" "),
});
}
const escEnd = text.indexOf("m", escStart);
if (escEnd === -1) break;
// Handle multiple codes in one sequence (e.g., \x1b[1;31m)
const codesStr = text.slice(escStart + 2, escEnd);
const codes = codesStr.split(";").map((c) => Number.parseInt(c, 10));
if (codes.includes(0)) {
// Reset all formatting
currentClasses = [];
} else {
// Add new classes for each code
for (const code of codes) {
const className = ansiToTailwind[code];
if (className && !currentClasses.includes(className)) {
currentClasses.push(className);
}
}
}
currentIndex = escEnd + 1;
}
return segments;
}

View File

@@ -59,7 +59,10 @@ export const DockerTerminalModal = ({
{children} {children}
</DropdownMenuItem> </DropdownMenuItem>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-7xl"> <DialogContent
className="max-h-screen overflow-y-auto sm:max-w-7xl"
onEscapeKeyDown={(event) => event.preventDefault()}
>
<DialogHeader> <DialogHeader>
<DialogTitle>Docker Terminal</DialogTitle> <DialogTitle>Docker Terminal</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -73,7 +76,7 @@ export const DockerTerminalModal = ({
serverId={serverId || ""} serverId={serverId || ""}
/> />
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}> <Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent> <DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Are you sure you want to close the terminal? Are you sure you want to close the terminal?

View File

@@ -4,6 +4,7 @@ import { FitAddon } from "xterm-addon-fit";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AttachAddon } from "@xterm/addon-attach"; import { AttachAddon } from "@xterm/addon-attach";
import { useTheme } from "next-themes";
interface Props { interface Props {
id: string; id: string;
@@ -18,6 +19,7 @@ export const DockerTerminal: React.FC<Props> = ({
}) => { }) => {
const termRef = useRef(null); const termRef = useRef(null);
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash"); const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
const { resolvedTheme } = useTheme();
useEffect(() => { useEffect(() => {
const container = document.getElementById(id); const container = document.getElementById(id);
if (container) { if (container) {
@@ -28,8 +30,9 @@ export const DockerTerminal: React.FC<Props> = ({
lineHeight: 1.4, lineHeight: 1.4,
convertEol: true, convertEol: true,
theme: { theme: {
cursor: "transparent", cursor: resolvedTheme === "light" ? "#000000" : "transparent",
background: "rgba(0, 0, 0, 0)", background: "rgba(0, 0, 0, 0)",
foreground: "currentColor",
}, },
}); });
const addonFit = new FitAddon(); const addonFit = new FitAddon();

View File

@@ -471,6 +471,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
<FormControl> <FormControl>
<Input <Input
placeholder={`Default ${databasesUserDefaultPlaceholder[type]}`} placeholder={`Default ${databasesUserDefaultPlaceholder[type]}`}
autoComplete="off"
{...field} {...field}
/> />
</FormControl> </FormControl>
@@ -491,6 +492,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
<Input <Input
type="password" type="password"
placeholder="******************" placeholder="******************"
autoComplete="off"
{...field} {...field}
/> />
</FormControl> </FormControl>

View File

@@ -47,7 +47,7 @@ export const ShowProjects = () => {
}, },
{ {
enabled: !!auth?.id && auth?.rol === "user", enabled: !!auth?.id && auth?.rol === "user",
} },
); );
const { mutateAsync } = api.project.remove.useMutation(); const { mutateAsync } = api.project.remove.useMutation();
@@ -87,7 +87,7 @@ export const ShowProjects = () => {
]; ];
const renderDomainsDropdown = ( const renderDomainsDropdown = (
item: typeof project.compose | typeof project.applications item: typeof project.compose | typeof project.applications,
) => ) =>
item[0] ? ( item[0] ? (
<DropdownMenuGroup> <DropdownMenuGroup>
@@ -247,12 +247,12 @@ export const ShowProjects = () => {
}) })
.then(() => { .then(() => {
toast.success( toast.success(
"Project delete succesfully" "Project delete succesfully",
); );
}) })
.catch(() => { .catch(() => {
toast.error( toast.error(
"Error to delete this project" "Error to delete this project",
); );
}) })
.finally(() => { .finally(() => {

View File

@@ -1,23 +1,5 @@
"use client"; "use client";
import React from "react";
import {
Command,
CommandEmpty,
CommandList,
CommandGroup,
CommandInput,
CommandItem,
CommandDialog,
CommandSeparator,
} from "@/components/ui/command";
import { useRouter } from "next/router";
import {
extractServices,
type Services,
} from "@/pages/dashboard/project/[projectId]";
import type { findProjectById } from "@dokploy/server/services/project";
import { BookIcon, CircuitBoard, GlobeIcon } from "lucide-react";
import { import {
MariadbIcon, MariadbIcon,
MongodbIcon, MongodbIcon,
@@ -25,8 +7,26 @@ import {
PostgresqlIcon, PostgresqlIcon,
RedisIcon, RedisIcon,
} from "@/components/icons/data-tools-icons"; } from "@/components/icons/data-tools-icons";
import { api } from "@/utils/api";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import {
type Services,
extractServices,
} from "@/pages/dashboard/project/[projectId]";
import { api } from "@/utils/api";
import type { findProjectById } from "@dokploy/server/services/project";
import { BookIcon, CircuitBoard, GlobeIcon } from "lucide-react";
import { useRouter } from "next/router";
import React from "react";
import { StatusTooltip } from "../shared/status-tooltip"; import { StatusTooltip } from "../shared/status-tooltip";
type Project = Awaited<ReturnType<typeof findProjectById>>; type Project = Awaited<ReturnType<typeof findProjectById>>;
@@ -89,7 +89,7 @@ export const SearchCommand = () => {
key={application.id} key={application.id}
onSelect={() => { onSelect={() => {
router.push( router.push(
`/dashboard/project/${project.projectId}/services/${application.type}/${application.id}` `/dashboard/project/${project.projectId}/services/${application.type}/${application.id}`,
); );
setOpen(false); setOpen(false);
}} }}

View File

@@ -45,6 +45,9 @@ import { z } from "zod";
const certificateDataHolder = const certificateDataHolder =
"-----BEGIN CERTIFICATE-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n------END CERTIFICATE-----"; "-----BEGIN CERTIFICATE-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n------END CERTIFICATE-----";
const privateKeyDataHolder =
"-----BEGIN PRIVATE KEY-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n-----END PRIVATE KEY-----";
const addCertificate = z.object({ const addCertificate = z.object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
certificateData: z.string().min(1, "Certificate data is required"), certificateData: z.string().min(1, "Certificate data is required"),
@@ -154,7 +157,7 @@ export const AddCertificate = () => {
<FormControl> <FormControl>
<Textarea <Textarea
className="h-32" className="h-32"
placeholder={certificateDataHolder} placeholder={privateKeyDataHolder}
{...field} {...field}
/> />
</FormControl> </FormControl>

View File

@@ -6,13 +6,144 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { ShieldCheck } from "lucide-react"; import { AlertCircle, Link, ShieldCheck } from "lucide-react";
import { AddCertificate } from "./add-certificate"; import { AddCertificate } from "./add-certificate";
import { DeleteCertificate } from "./delete-certificate"; import { DeleteCertificate } from "./delete-certificate";
export const ShowCertificates = () => { export const ShowCertificates = () => {
const { data } = api.certificates.all.useQuery(); const { data } = api.certificates.all.useQuery();
const extractExpirationDate = (certData: string): Date | null => {
try {
const match = certData.match(
/-----BEGIN CERTIFICATE-----\s*([^-]+)\s*-----END CERTIFICATE-----/,
);
if (!match?.[1]) return null;
const base64Cert = match[1].replace(/\s/g, "");
const binaryStr = window.atob(base64Cert);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
let dateFound = 0;
for (let i = 0; i < bytes.length - 2; i++) {
if (bytes[i] === 0x17 || bytes[i] === 0x18) {
const dateType = bytes[i];
const dateLength = bytes[i + 1];
if (typeof dateLength === "undefined") continue;
if (dateFound === 0) {
dateFound++;
i += dateLength + 1;
continue;
}
let dateStr = "";
for (let j = 0; j < dateLength; j++) {
const charCode = bytes[i + 2 + j];
if (typeof charCode === "undefined") continue;
dateStr += String.fromCharCode(charCode);
}
if (dateType === 0x17) {
// UTCTime (YYMMDDhhmmssZ)
const year = Number.parseInt(dateStr.slice(0, 2));
const fullYear = year >= 50 ? 1900 + year : 2000 + year;
return new Date(
Date.UTC(
fullYear,
Number.parseInt(dateStr.slice(2, 4)) - 1,
Number.parseInt(dateStr.slice(4, 6)),
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
),
);
}
// GeneralizedTime (YYYYMMDDhhmmssZ)
return new Date(
Date.UTC(
Number.parseInt(dateStr.slice(0, 4)),
Number.parseInt(dateStr.slice(4, 6)) - 1,
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
Number.parseInt(dateStr.slice(12, 14)),
),
);
}
}
return null;
} catch (error) {
console.error("Error parsing certificate:", error);
return null;
}
};
const getExpirationStatus = (certData: string) => {
const expirationDate = extractExpirationDate(certData);
if (!expirationDate)
return {
status: "unknown" as const,
className: "text-muted-foreground",
message: "Could not determine expiration",
};
const now = new Date();
const daysUntilExpiration = Math.ceil(
(expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
if (daysUntilExpiration < 0) {
return {
status: "expired" as const,
className: "text-red-500",
message: `Expired on ${expirationDate.toLocaleDateString([], {
year: "numeric",
month: "long",
day: "numeric",
})}`,
};
}
if (daysUntilExpiration <= 30) {
return {
status: "warning" as const,
className: "text-yellow-500",
message: `Expires in ${daysUntilExpiration} days`,
};
}
return {
status: "valid" as const,
className: "text-muted-foreground",
message: `Expires ${expirationDate.toLocaleDateString([], {
year: "numeric",
month: "long",
day: "numeric",
})}`,
};
};
const getCertificateChainInfo = (certData: string) => {
const certCount = (certData.match(/-----BEGIN CERTIFICATE-----/g) || [])
.length;
return certCount > 1
? {
isChain: true,
count: certCount,
}
: {
isChain: false,
count: 1,
};
};
return ( return (
<div className="h-full"> <div className="h-full">
<Card className="bg-transparent h-full"> <Card className="bg-transparent h-full">
@@ -23,7 +154,7 @@ export const ShowCertificates = () => {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2 pt-4 h-full"> <CardContent className="space-y-2 pt-4 h-full">
{data?.length === 0 ? ( {!data?.length ? (
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<ShieldCheck className="size-8 self-center text-muted-foreground" /> <ShieldCheck className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground"> <span className="text-base text-muted-foreground">
@@ -35,21 +166,53 @@ export const ShowCertificates = () => {
) : ( ) : (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{data?.map((destination, index) => ( {data.map((certificate, index) => {
const expiration = getExpirationStatus(
certificate.certificateData,
);
const chainInfo = getCertificateChainInfo(
certificate.certificateData,
);
return (
<div <div
key={destination.certificateId} key={certificate.certificateId}
className="flex items-center justify-between border p-4 rounded-lg" className="flex flex-col border p-4 rounded-lg space-y-2"
> >
<span className="text-sm text-muted-foreground"> <div className="flex items-center justify-between">
{index + 1}. {destination.name} <div className="flex items-center gap-2">
<span className="text-sm font-medium">
{index + 1}. {certificate.name}
</span> </span>
<div className="flex flex-row gap-3"> {chainInfo.isChain && (
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50">
<Link className="size-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
Chain ({chainInfo.count})
</span>
</div>
)}
</div>
<DeleteCertificate <DeleteCertificate
certificateId={destination.certificateId} certificateId={certificate.certificateId}
/> />
</div> </div>
<div
className={`text-xs flex items-center gap-1.5 ${expiration.className}`}
>
{expiration.status !== "valid" && (
<AlertCircle className="size-3" />
)}
{expiration.message}
{certificate.autoRenew &&
expiration.status !== "valid" && (
<span className="text-xs text-emerald-500 ml-1">
(Auto-renewal enabled)
</span>
)}
</div> </div>
))} </div>
);
})}
</div> </div>
<div> <div>
<AddCertificate /> <AddCertificate />

View File

@@ -159,7 +159,11 @@ export const AddRegistry = () => {
<FormItem> <FormItem>
<FormLabel>Username</FormLabel> <FormLabel>Username</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Username" {...field} /> <Input
placeholder="Username"
autoComplete="off"
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -177,6 +181,7 @@ export const AddRegistry = () => {
<FormControl> <FormControl>
<Input <Input
placeholder="Password" placeholder="Password"
autoComplete="off"
{...field} {...field}
type="password" type="password"
/> />

View File

@@ -64,6 +64,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
.object({ .object({
type: z.literal("discord"), type: z.literal("discord"),
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }), webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
decoration: z.boolean().default(true),
}) })
.merge(notificationBaseSchema), .merge(notificationBaseSchema),
z z
@@ -195,6 +196,7 @@ export const AddNotification = () => {
dokployRestart: dokployRestart, dokployRestart: dokployRestart,
databaseBackup: databaseBackup, databaseBackup: databaseBackup,
webhookUrl: data.webhookUrl, webhookUrl: data.webhookUrl,
decoration: data.decoration,
name: data.name, name: data.name,
dockerCleanup: dockerCleanup, dockerCleanup: dockerCleanup,
}); });
@@ -397,6 +399,7 @@ export const AddNotification = () => {
)} )}
{type === "discord" && ( {type === "discord" && (
<>
<FormField <FormField
control={form.control} control={form.control}
name="webhookUrl" name="webhookUrl"
@@ -414,6 +417,29 @@ export const AddNotification = () => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="decoration"
defaultValue={true}
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Decoration</FormLabel>
<FormDescription>
Decorate the notification with emojis.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</>
)} )}
{type === "email" && ( {type === "email" && (
@@ -708,6 +734,7 @@ export const AddNotification = () => {
} else if (type === "discord") { } else if (type === "discord") {
await testDiscordConnection({ await testDiscordConnection({
webhookUrl: form.getValues("webhookUrl"), webhookUrl: form.getValues("webhookUrl"),
decoration: form.getValues("decoration"),
}); });
} else if (type === "email") { } else if (type === "email") {
await testEmailConnection({ await testEmailConnection({

View File

@@ -11,7 +11,7 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { TrashIcon } from "lucide-react"; import { Trash2 } from "lucide-react";
import React from "react"; import React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -24,8 +24,13 @@ export const DeleteNotification = ({ notificationId }: Props) => {
return ( return (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}> <Button
<TrashIcon className="size-4 text-muted-foreground" /> variant="ghost"
size="icon"
className="h-9 w-9 group hover:bg-red-500/10"
isLoading={isLoading}
>
<Trash2 className="size-4 text-muted-foreground group-hover:text-red-500" />
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>

View File

@@ -40,34 +40,45 @@ export const ShowNotifications = () => {
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="grid lg:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid lg:grid-cols-1 xl:grid-cols-2 gap-4">
{data?.map((notification, index) => ( {data?.map((notification, index) => (
<div <div
key={notification.notificationId} key={notification.notificationId}
className="flex items-center justify-between border gap-2 p-3.5 rounded-lg" className="flex items-center justify-between rounded-xl p-4 transition-colors dark:bg-zinc-900/50 bg-gray-200/50 border border-card"
> >
<div className="flex flex-row gap-2 items-center w-full "> <div className="flex items-center gap-4">
{notification.notificationType === "slack" && ( {notification.notificationType === "slack" && (
<SlackIcon className="text-muted-foreground size-6 flex-shrink-0" /> <div className="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-500/10">
<SlackIcon className="h-6 w-6 text-indigo-400" />
</div>
)} )}
{notification.notificationType === "telegram" && ( {notification.notificationType === "telegram" && (
<TelegramIcon className="text-muted-foreground size-8 flex-shrink-0" /> <div className="flex h-12 w-12 items-center justify-center rounded-lg bg-cyan-500/10">
<TelegramIcon className="h-6 w-6 text-indigo-400" />
</div>
)} )}
{notification.notificationType === "discord" && ( {notification.notificationType === "discord" && (
<DiscordIcon className="text-muted-foreground size-7 flex-shrink-0" /> <div className="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-500/10">
<DiscordIcon className="h-6 w-6 text-indigo-400" />
</div>
)} )}
{notification.notificationType === "email" && ( {notification.notificationType === "email" && (
<Mail <div className="flex h-12 w-12 items-center justify-center rounded-lg bg-zinc-500/10">
size={29} <Mail className="h-6 w-6 text-indigo-400" />
className="text-muted-foreground size-6 flex-shrink-0" </div>
/>
)} )}
<span className="text-sm text-muted-foreground"> <div className="flex flex-col">
<span className="text-sm font-medium dark:text-zinc-300 text-zinc-800">
{notification.name} {notification.name}
</span> </span>
<span className="text-xs font-medium text-muted-foreground">
{notification.notificationType?.[0]?.toUpperCase() +
notification.notificationType?.slice(1)}{" "}
notification
</span>
</div> </div>
</div>
<div className="flex flex-row gap-1 w-fit"> <div className="flex items-center gap-2">
<UpdateNotification <UpdateNotification
notificationId={notification.notificationId} notificationId={notification.notificationId}
/> />
@@ -78,6 +89,7 @@ export const ShowNotifications = () => {
</div> </div>
))} ))}
</div> </div>
<div className="flex flex-col gap-4 justify-end w-full items-end"> <div className="flex flex-col gap-4 justify-end w-full items-end">
<AddNotification /> <AddNotification />
</div> </div>

View File

@@ -26,9 +26,9 @@ import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Mail, PenBoxIcon } from "lucide-react"; import { Mail, Pen } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FieldErrors, useFieldArray, useForm } from "react-hook-form"; import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
type NotificationSchema, type NotificationSchema,
@@ -113,6 +113,7 @@ export const UpdateNotification = ({ notificationId }: Props) => {
databaseBackup: data.databaseBackup, databaseBackup: data.databaseBackup,
type: data.notificationType, type: data.notificationType,
webhookUrl: data.discord?.webhookUrl, webhookUrl: data.discord?.webhookUrl,
decoration: data.discord?.decoration || undefined,
name: data.name, name: data.name,
dockerCleanup: data.dockerCleanup, dockerCleanup: data.dockerCleanup,
}); });
@@ -178,6 +179,7 @@ export const UpdateNotification = ({ notificationId }: Props) => {
dokployRestart: dokployRestart, dokployRestart: dokployRestart,
databaseBackup: databaseBackup, databaseBackup: databaseBackup,
webhookUrl: formData.webhookUrl, webhookUrl: formData.webhookUrl,
decoration: formData.decoration,
name: formData.name, name: formData.name,
notificationId: notificationId, notificationId: notificationId,
discordId: data?.discordId, discordId: data?.discordId,
@@ -218,8 +220,12 @@ export const UpdateNotification = ({ notificationId }: Props) => {
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild> <DialogTrigger className="" asChild>
<Button variant="ghost"> <Button
<PenBoxIcon className="size-4 text-muted-foreground" /> variant="ghost"
size="icon"
className="h-9 w-9 dark:hover:bg-zinc-900/80 hover:bg-gray-200/80"
>
<Pen className="size-4 text-muted-foreground" />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl"> <DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
@@ -356,6 +362,7 @@ export const UpdateNotification = ({ notificationId }: Props) => {
)} )}
{type === "discord" && ( {type === "discord" && (
<>
<FormField <FormField
control={form.control} control={form.control}
name="webhookUrl" name="webhookUrl"
@@ -373,6 +380,28 @@ export const UpdateNotification = ({ notificationId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="decoration"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Decoration</FormLabel>
<FormDescription>
Decorate the notification with emojis.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</>
)} )}
{type === "email" && ( {type === "email" && (
<> <>
@@ -667,6 +696,7 @@ export const UpdateNotification = ({ notificationId }: Props) => {
} else if (type === "discord") { } else if (type === "discord") {
await testDiscordConnection({ await testDiscordConnection({
webhookUrl: form.getValues("webhookUrl"), webhookUrl: form.getValues("webhookUrl"),
decoration: form.getValues("decoration"),
}); });
} else if (type === "email") { } else if (type === "email") {
await testEmailConnection({ await testEmailConnection({

View File

@@ -1,3 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -26,7 +27,6 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { Disable2FA } from "./disable-2fa"; import { Disable2FA } from "./disable-2fa";
import { Enable2FA } from "./enable-2fa"; import { Enable2FA } from "./enable-2fa";
import { AlertBlock } from "@/components/shared/alert-block";
const profileSchema = z.object({ const profileSchema = z.object({
email: z.string(), email: z.string(),
@@ -104,6 +104,7 @@ export const ProfileForm = () => {
.then(async () => { .then(async () => {
await refetch(); await refetch();
toast.success("Profile Updated"); toast.success("Profile Updated");
form.reset();
}) })
.catch(() => { .catch(() => {
toast.error("Error to Update the profile"); toast.error("Error to Update the profile");

View File

@@ -1,3 +1,5 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -18,13 +20,11 @@ import { Input } from "@/components/ui/input";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useRouter } from "next/router";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { DialogAction } from "@/components/shared/dialog-action";
import { AlertBlock } from "@/components/shared/alert-block";
import { useRouter } from "next/router";
const profileSchema = z.object({ const profileSchema = z.object({
password: z.string().min(1, { password: z.string().min(1, {

View File

@@ -25,6 +25,7 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { EditTraefikEnv } from "../../web-server/edit-traefik-env"; import { EditTraefikEnv } from "../../web-server/edit-traefik-env";
import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports";
import { ShowModalLogs } from "../../web-server/show-modal-logs"; import { ShowModalLogs } from "../../web-server/show-modal-logs";
interface Props { interface Props {
@@ -128,6 +129,14 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
<span>Enter the terminal</span> <span>Enter the terminal</span>
</DropdownMenuItem> </DropdownMenuItem>
</DockerTerminalModal> */} </DockerTerminalModal> */}
<ManageTraefikPorts serverId={serverId}>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
<span>{t("settings.server.webServer.traefik.managePorts")}</span>
</DropdownMenuItem>
</ManageTraefikPorts>
</DropdownMenuGroup> </DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -108,7 +108,8 @@ export const EditScript = ({ serverId }: Props) => {
</DialogDescription> </DialogDescription>
<AlertBlock type="warning"> <AlertBlock type="warning">
We recommend not modifying this script unless you know what you are doing. We recommend not modifying this script unless you know what you are
doing.
</AlertBlock> </AlertBlock>
</DialogHeader> </DialogHeader>
<div className="grid gap-4"> <div className="grid gap-4">

View File

@@ -34,8 +34,8 @@ import { toast } from "sonner";
import { ShowDeployment } from "../../application/deployments/show-deployment"; import { ShowDeployment } from "../../application/deployments/show-deployment";
import { EditScript } from "./edit-script"; import { EditScript } from "./edit-script";
import { GPUSupport } from "./gpu-support"; import { GPUSupport } from "./gpu-support";
import { ValidateServer } from "./validate-server";
import { SecurityAudit } from "./security-audit"; import { SecurityAudit } from "./security-audit";
import { ValidateServer } from "./validate-server";
interface Props { interface Props {
serverId: string; serverId: string;

View File

@@ -23,15 +23,16 @@ import { api } from "@/utils/api";
import { format } from "date-fns"; import { format } from "date-fns";
import { KeyIcon, MoreHorizontal, ServerIcon } from "lucide-react"; import { KeyIcon, MoreHorizontal, ServerIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
import { toast } from "sonner"; import { toast } from "sonner";
import { TerminalModal } from "../web-server/terminal-modal"; import { TerminalModal } from "../web-server/terminal-modal";
import { ShowServerActions } from "./actions/show-server-actions"; import { ShowServerActions } from "./actions/show-server-actions";
import { AddServer } from "./add-server"; import { AddServer } from "./add-server";
import { SetupServer } from "./setup-server"; import { SetupServer } from "./setup-server";
import { ShowDockerContainersModal } from "./show-docker-containers-modal"; import { ShowDockerContainersModal } from "./show-docker-containers-modal";
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal"; import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { UpdateServer } from "./update-server"; import { UpdateServer } from "./update-server";
import { useRouter } from "next/router";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription"; import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
export const ShowServers = () => { export const ShowServers = () => {
@@ -259,6 +260,9 @@ export const ShowServers = () => {
<ShowDockerContainersModal <ShowDockerContainersModal
serverId={server.serverId} serverId={server.serverId}
/> />
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
</> </>
)} )}
</DropdownMenuContent> </DropdownMenuContent>

View File

@@ -0,0 +1,51 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ContainerIcon } from "lucide-react";
import { useState } from "react";
import SwarmMonitorCard from "../../swarm/monitoring-card";
interface Props {
serverId: string;
}
export const ShowSwarmOverviewModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Swarm Overview
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl overflow-y-auto max-h-screen ">
<DialogHeader>
<div className="flex flex-col gap-1.5">
<DialogTitle className="flex items-center gap-2">
<ContainerIcon className="size-5" />
Swarm Overview
</DialogTitle>
<p className="text-muted-foreground text-sm">
See all details of your swarm node
</p>
</div>
</DialogHeader>
<div className="grid w-full gap-1">
<div className="flex flex-wrap gap-4 py-4">
<SwarmMonitorCard serverId={serverId} />
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,12 +1,12 @@
import { CodeEditor } from "@/components/shared/code-editor";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { ExternalLinkIcon, Loader2 } from "lucide-react";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { ExternalLinkIcon, Loader2 } from "lucide-react";
import { CopyIcon } from "lucide-react"; import { CopyIcon } from "lucide-react";
import Link from "next/link";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { CodeEditor } from "@/components/shared/code-editor";
import Link from "next/link";
export const CreateSSHKey = () => { export const CreateSSHKey = () => {
const { data, refetch } = api.sshKey.all.useQuery(); const { data, refetch } = api.sshKey.all.useQuery();

View File

@@ -5,26 +5,26 @@ import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardContent,
CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { RocketIcon } from "lucide-react";
import { toast } from "sonner";
import { EditScript } from "../edit-script";
import { api } from "@/utils/api";
import { useState } from "react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
Select, Select,
SelectTrigger,
SelectValue,
SelectContent, SelectContent,
SelectGroup, SelectGroup,
SelectItem, SelectItem,
SelectLabel, SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { api } from "@/utils/api";
import { RocketIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { EditScript } from "../edit-script";
export const Setup = () => { export const Setup = () => {
const { data: servers } = api.server.all.useQuery(); const { data: servers } = api.server.all.useQuery();

View File

@@ -1,27 +1,27 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardContent,
CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Loader2, PcCase, RefreshCw } from "lucide-react";
import { api } from "@/utils/api";
import { useState } from "react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { Loader2, PcCase, RefreshCw } from "lucide-react";
import { useState } from "react";
import { AlertBlock } from "@/components/shared/alert-block";
import { import {
Select, Select,
SelectTrigger,
SelectValue,
SelectContent, SelectContent,
SelectGroup, SelectGroup,
SelectItem, SelectItem,
SelectLabel, SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { StatusRow } from "../gpu-support"; import { StatusRow } from "../gpu-support";
import { AlertBlock } from "@/components/shared/alert-block";
export const Verify = () => { export const Verify = () => {
const { data: servers } = api.server.all.useQuery(); const { data: servers } = api.server.all.useQuery();

View File

@@ -1,3 +1,5 @@
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -7,21 +9,19 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { defineStepper } from "@stepperize/react";
import { BookIcon, Puzzle } from "lucide-react"; import { BookIcon, Puzzle } from "lucide-react";
import { Code2, Database, GitMerge, Globe, Plug, Users } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { defineStepper } from "@stepperize/react";
import React from "react"; import React from "react";
import { Separator } from "@/components/ui/separator"; import ConfettiExplosion from "react-confetti-explosion";
import { AlertBlock } from "@/components/shared/alert-block";
import { CreateServer } from "./create-server"; import { CreateServer } from "./create-server";
import { CreateSSHKey } from "./create-ssh-key"; import { CreateSSHKey } from "./create-ssh-key";
import { Setup } from "./setup"; import { Setup } from "./setup";
import { Verify } from "./verify"; import { Verify } from "./verify";
import { Database, Globe, GitMerge, Users, Code2, Plug } from "lucide-react";
import ConfettiExplosion from "react-confetti-explosion";
import Link from "next/link";
import { GithubIcon } from "@/components/icons/data-tools-icons";
export const { useStepper, steps, Scoped } = defineStepper( export const { useStepper, steps, Scoped } = defineStepper(
{ {

View File

@@ -80,7 +80,10 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => {
return ( return (
<Dialog open={mainDialogOpen} onOpenChange={handleMainDialogOpenChange}> <Dialog open={mainDialogOpen} onOpenChange={handleMainDialogOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-7xl"> <DialogContent
className="max-h-[85vh] overflow-y-auto sm:max-w-7xl"
onEscapeKeyDown={(event) => event.preventDefault()}
>
<DialogHeader> <DialogHeader>
<DialogTitle>Docker Terminal</DialogTitle> <DialogTitle>Docker Terminal</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -119,7 +122,7 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => {
containerId={containerId || "select-a-container"} containerId={containerId || "select-a-container"}
/> />
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}> <Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent> <DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Are you sure you want to close the terminal? Are you sure you want to close the terminal?

View File

@@ -80,8 +80,10 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-4xl"> <DialogContent className="max-h-screen overflow-y-auto sm:max-w-4xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Update Traefik Env</DialogTitle> <DialogTitle>Update Traefik Environment</DialogTitle>
<DialogDescription>Update the traefik env</DialogDescription> <DialogDescription>
Update the traefik environment variables
</DialogDescription>
</DialogHeader> </DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>} {isError && <AlertBlock type="error">{error?.message}</AlertBlock>}

View File

@@ -0,0 +1,303 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
import { useTranslation } from "next-i18next";
import type React from "react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
interface Props {
children: React.ReactNode;
serverId?: string;
}
const PortSchema = z.object({
targetPort: z.number().min(1, "Target port is required"),
publishedPort: z.number().min(1, "Published port is required"),
publishMode: z.enum(["ingress", "host"]),
});
const TraefikPortsSchema = z.object({
ports: z.array(PortSchema),
});
type TraefikPortsForm = z.infer<typeof TraefikPortsSchema>;
export const ManageTraefikPorts = ({ children, serverId }: Props) => {
const { t } = useTranslation("settings");
const [open, setOpen] = useState(false);
const form = useForm<TraefikPortsForm>({
resolver: zodResolver(TraefikPortsSchema),
defaultValues: {
ports: [],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "ports",
});
const { data: currentPorts, refetch: refetchPorts } =
api.settings.getTraefikPorts.useQuery({
serverId,
});
const { mutateAsync: updatePorts, isLoading } =
api.settings.updateTraefikPorts.useMutation({
onSuccess: () => {
refetchPorts();
},
});
useEffect(() => {
if (currentPorts) {
form.reset({ ports: currentPorts });
}
}, [currentPorts, form]);
const handleAddPort = () => {
append({ targetPort: 0, publishedPort: 0, publishMode: "host" });
};
const onSubmit = async (data: TraefikPortsForm) => {
try {
await updatePorts({
serverId,
additionalPorts: data.ports,
});
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
setOpen(false);
} catch (error) {
toast.error(t("settings.server.webServer.traefik.portsUpdateError"));
}
};
return (
<>
<div onClick={() => setOpen(true)}>{children}</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-xl">
{t("settings.server.webServer.traefik.managePorts")}
</DialogTitle>
<DialogDescription className="text-base w-full">
<div className="flex items-center justify-between">
{t("settings.server.webServer.traefik.managePortsDescription")}
<Button
onClick={handleAddPort}
variant="default"
className="gap-2"
>
<Plus className="h-4 w-4" />
Add Mapping
</Button>
</div>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid gap-6 py-4">
{fields.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<ArrowRightLeft className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
No port mappings configured
</span>
<p className="text-sm text-muted-foreground text-center">
Add one to get started
</p>
</div>
) : (
<div className="grid gap-4">
{fields.map((field, index) => (
<Card key={field.id}>
<CardContent className="grid grid-cols-[1fr_1fr_1.5fr_auto] gap-4 p-4 transparent">
<FormField
control={form.control}
name={`ports.${index}.targetPort`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium text-muted-foreground">
{t(
"settings.server.webServer.traefik.targetPort",
)}
</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
className="w-full dark:bg-black"
placeholder="e.g. 8080"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ports.${index}.publishedPort`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium text-muted-foreground">
{t(
"settings.server.webServer.traefik.publishedPort",
)}
</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
className="w-full dark:bg-black"
placeholder="e.g. 80"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ports.${index}.publishMode`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium text-muted-foreground">
{t(
"settings.server.webServer.traefik.publishMode",
)}
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger className="dark:bg-black">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="host">
Host Mode
</SelectItem>
<SelectItem value="ingress">
Ingress Mode
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-end">
<Button
onClick={() => remove(index)}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{fields.length > 0 && (
<AlertBlock type="info">
<div className="flex flex-col gap-2">
<span className="text-sm">
<strong>
Each port mapping defines how external traffic reaches
your containers.
</strong>
<ul className="pt-2">
<li>
<strong>Host Mode:</strong> Directly binds the port
to the host machine.
<ul className="p-2 list-inside list-disc">
<li>
Best for single-node deployments or when you
need guaranteed port availability.
</li>
</ul>
</li>
<li>
<strong>Ingress Mode:</strong> Routes through Docker
Swarm's load balancer.
<ul className="p-2 list-inside list-disc">
<li>
Recommended for multi-node deployments and
better scalability.
</li>
</ul>
</li>
</ul>
</span>
</div>
</AlertBlock>
)}
</div>
<DialogFooter>
<Button
type="submit"
variant="default"
className="text-sm"
isLoading={isLoading}
>
Save
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</>
);
};
export default ManageTraefikPorts;

View File

@@ -4,6 +4,7 @@ import { useEffect, useRef } from "react";
import { FitAddon } from "xterm-addon-fit"; import { FitAddon } from "xterm-addon-fit";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { AttachAddon } from "@xterm/addon-attach"; import { AttachAddon } from "@xterm/addon-attach";
import { useTheme } from "next-themes";
interface Props { interface Props {
id: string; id: string;
@@ -12,7 +13,7 @@ interface Props {
export const Terminal: React.FC<Props> = ({ id, serverId }) => { export const Terminal: React.FC<Props> = ({ id, serverId }) => {
const termRef = useRef(null); const termRef = useRef(null);
const { resolvedTheme } = useTheme();
useEffect(() => { useEffect(() => {
const container = document.getElementById(id); const container = document.getElementById(id);
if (container) { if (container) {
@@ -23,8 +24,9 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
lineHeight: 1.4, lineHeight: 1.4,
convertEol: true, convertEol: true,
theme: { theme: {
cursor: "transparent", cursor: resolvedTheme === "light" ? "#000000" : "transparent",
background: "transparent", background: "rgba(0, 0, 0, 0)",
foreground: "currentColor",
}, },
}); });
const addonFit = new FitAddon(); const addonFit = new FitAddon();

View File

@@ -0,0 +1,28 @@
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useState } from "react";
export const ToggleAutoCheckUpdates = ({ disabled }: { disabled: boolean }) => {
const [enabled, setEnabled] = useState<boolean>(
localStorage.getItem("enableAutoCheckUpdates") === "true",
);
const handleToggle = (checked: boolean) => {
setEnabled(checked);
localStorage.setItem("enableAutoCheckUpdates", String(checked));
};
return (
<div className="flex items-center gap-4">
<Switch
checked={enabled}
onCheckedChange={handleToggle}
id="autoCheckUpdatesToggle"
disabled={disabled}
/>
<Label className="text-primary" htmlFor="autoCheckUpdatesToggle">
Automatically check for new updates
</Label>
</div>
);
};

View File

@@ -3,91 +3,224 @@ import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { RefreshCcw } from "lucide-react"; import {
Bug,
Download,
Info,
RefreshCcw,
Server,
ServerCrash,
Sparkles,
Stars,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ToggleAutoCheckUpdates } from "./toggle-auto-check-updates";
import { UpdateWebServer } from "./update-webserver"; import { UpdateWebServer } from "./update-webserver";
export const UpdateServer = () => { export const UpdateServer = () => {
const [isUpdateAvailable, setIsUpdateAvailable] = useState<null | boolean>( const [hasCheckedUpdate, setHasCheckedUpdate] = useState(false);
null, const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
); const { mutateAsync: getUpdateData, isLoading } =
const { mutateAsync: checkAndUpdateImage, isLoading } = api.settings.getUpdateData.useMutation();
api.settings.checkAndUpdateImage.useMutation(); const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
const { data: releaseTag } = api.settings.getReleaseTag.useQuery();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [latestVersion, setLatestVersion] = useState("");
const handleCheckUpdates = async () => {
try {
const updateData = await getUpdateData();
const versionToUpdate = updateData.latestVersion || "";
setHasCheckedUpdate(true);
setIsUpdateAvailable(updateData.updateAvailable);
setLatestVersion(versionToUpdate);
if (updateData.updateAvailable) {
toast.success(versionToUpdate, {
description: "New version available!",
});
} else {
toast.info("No updates available");
}
} catch (error) {
console.error("Error checking for updates:", error);
setHasCheckedUpdate(true);
setIsUpdateAvailable(false);
toast.error(
"An error occurred while checking for updates, please try again.",
);
}
};
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="secondary"> <Button variant="secondary" className="gap-2">
<RefreshCcw className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
Updates Updates
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:m:max-w-lg "> <DialogContent className="max-w-lg p-6">
<DialogHeader> <div className="flex items-center justify-between mb-8">
<DialogTitle>Web Server Update</DialogTitle> <DialogTitle className="text-2xl font-semibold">
<DialogDescription> Web Server Update
Check new releases and update your dokploy </DialogTitle>
</DialogDescription> {dokployVersion && (
</DialogHeader> <div className="flex items-center gap-1.5 rounded-full px-3 py-1 mr-2 bg-muted">
<Server className="h-4 w-4 text-muted-foreground" />
<div className="flex flex-col gap-4">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
We suggest to update your dokploy to the latest version only if you: {dokployVersion} | {releaseTag}
</span>
<ul className="list-disc list-inside text-sm text-muted-foreground">
<li>Want to try the latest features</li>
<li>Some bug that is blocking to use some features</li>
</ul>
<AlertBlock type="info">
We recommend checking the latest version for any breaking changes
before updating. Go to{" "}
<Link
href="https://github.com/Dokploy/dokploy/releases"
target="_blank"
className="text-foreground"
>
Dokploy Releases
</Link>{" "}
to check the latest version.
</AlertBlock>
<div className="w-full flex flex-col gap-4">
{isUpdateAvailable === false && (
<div className="flex flex-col items-center gap-3">
<RefreshCcw className="size-6 self-center text-muted-foreground" />
<span className="text-sm text-muted-foreground">
You are using the latest version
</span> </span>
</div> </div>
)} )}
</div>
{/* Initial state */}
{!hasCheckedUpdate && (
<div className="mb-8">
<p className="text text-muted-foreground">
Check for new releases and update Dokploy.
<br />
<br />
We recommend checking for updates regularly to ensure you have the
latest features and security improvements.
</p>
</div>
)}
{/* Update available state */}
{isUpdateAvailable && latestVersion && (
<div className="mb-8">
<div className="inline-flex items-center gap-2 rounded-lg px-3 py-2 border border-emerald-900 bg-emerald-900 dark:bg-emerald-900/40 mb-4 w-full">
<div className="flex items-center gap-1.5">
<span className="flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-2 w-2 rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
</span>
<Download className="h-4 w-4 text-emerald-400" />
<span className="text font-medium text-emerald-400 ">
New version available:
</span>
</div>
<span className="text font-semibold text-emerald-300">
{latestVersion}
</span>
</div>
<div className="space-y-4 text-muted-foreground">
<p className="text">
A new version of the server software is available. Consider
updating if you:
</p>
<ul className="space-y-3">
<li className="flex items-start gap-2">
<Stars className="h-5 w-5 mt-0.5 text-[#5B9DFF]" />
<span className="text">
Want to access the latest features and improvements
</span>
</li>
<li className="flex items-start gap-2">
<Bug className="h-5 w-5 mt-0.5 text-[#5B9DFF]" />
<span className="text">
Are experiencing issues that may be resolved in the new
version
</span>
</li>
</ul>
</div>
</div>
)}
{/* Up to date state */}
{hasCheckedUpdate && !isUpdateAvailable && !isLoading && (
<div className="mb-8">
<div className="flex flex-col items-center gap-6 mb-6">
<div className="rounded-full p-4 bg-emerald-400/40">
<Sparkles className="h-8 w-8 text-emerald-400" />
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">
You are using the latest version
</h3>
<p className="text text-muted-foreground">
Your server is up to date with all the latest features and
security improvements.
</p>
</div>
</div>
</div>
)}
{hasCheckedUpdate && isLoading && (
<div className="mb-8">
<div className="flex flex-col items-center gap-6 mb-6">
<div className="rounded-full p-4 bg-[#5B9DFF]/40 text-foreground">
<RefreshCcw className="h-8 w-8 animate-spin" />
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">Checking for updates...</h3>
<p className="text text-muted-foreground">
Please wait while we pull the latest version information from
Docker Hub.
</p>
</div>
</div>
</div>
)}
{isUpdateAvailable && (
<div className="rounded-lg bg-[#16254D] p-4 mb-8">
<div className="flex gap-2">
<Info className="h-5 w-5 flex-shrink-0 text-[#5B9DFF]" />
<div className="text-[#5B9DFF]">
We recommend reviewing the{" "}
<Link
href="https://github.com/Dokploy/dokploy/releases"
target="_blank"
className="text-white underline hover:text-zinc-200"
>
release notes
</Link>{" "}
for any breaking changes before updating.
</div>
</div>
</div>
)}
<div className="flex items-center justify-between pt-2">
<ToggleAutoCheckUpdates disabled={isLoading} />
</div>
<div className="space-y-4 flex items-center justify-end">
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setIsOpen(false)}>
Cancel
</Button>
{isUpdateAvailable ? ( {isUpdateAvailable ? (
<UpdateWebServer /> <UpdateWebServer />
) : ( ) : (
<Button <Button
className="w-full" variant="secondary"
onClick={async () => { onClick={handleCheckUpdates}
await checkAndUpdateImage() disabled={isLoading}
.then(async (e) => {
setIsUpdateAvailable(e);
})
.catch(() => {
setIsUpdateAvailable(false);
toast.error("Error to check updates");
});
toast.success("Check updates");
}}
isLoading={isLoading}
> >
Check Updates {isLoading ? (
<>
<RefreshCcw className="h-4 w-4 animate-spin" />
Checking for updates
</>
) : (
<>
<RefreshCcw className="h-4 w-4" />
Check for updates
</>
)}
</Button> </Button>
)} )}
</div> </div>
@@ -96,3 +229,5 @@ export const UpdateServer = () => {
</Dialog> </Dialog>
); );
}; };
export default UpdateServer;

View File

@@ -11,24 +11,53 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { HardDriveDownload } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
export const UpdateWebServer = () => { interface Props {
isNavbar?: boolean;
}
export const UpdateWebServer = ({ isNavbar }: Props) => {
const { mutateAsync: updateServer, isLoading } = const { mutateAsync: updateServer, isLoading } =
api.settings.updateServer.useMutation(); api.settings.updateServer.useMutation();
const buttonLabel = isNavbar ? "Update available" : "Update Server";
const handleConfirm = async () => {
try {
await updateServer();
toast.success(
"The server has been updated. The page will be reloaded to reflect the changes...",
);
setTimeout(() => {
// Allow seeing the toast before reloading
window.location.reload();
}, 2000);
} catch (error) {
console.error("Error updating server:", error);
toast.error(
"An error occurred while updating the server, please try again.",
);
}
};
return ( return (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button
className="relative w-full" className="relative w-full"
variant="secondary" variant={isNavbar ? "outline" : "secondary"}
isLoading={isLoading} isLoading={isLoading}
> >
{!isLoading && <HardDriveDownload className="h-4 w-4" />}
{!isLoading && (
<span className="absolute -right-1 -top-2 flex h-3 w-3"> <span className="absolute -right-1 -top-2 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" /> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" /> <span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
</span> </span>
Update Server )}
{isLoading ? "Updating..." : buttonLabel}
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
@@ -36,19 +65,12 @@ export const UpdateWebServer = () => {
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone. This will update the web server to the This action cannot be undone. This will update the web server to the
new version. new version. The page will be reloaded once the update is finished.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction onClick={handleConfirm}>Confirm</AlertDialogAction>
onClick={async () => {
await updateServer();
toast.success("Please reload the browser to see the changes");
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

View File

@@ -0,0 +1,218 @@
import type { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { ShowNodeConfig } from "../details/show-node-config";
// import { ShowContainerConfig } from "../config/show-container-config";
// import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
// import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
// import type { Container } from "./show-containers";
export interface ApplicationList {
ID: string;
Image: string;
Mode: string;
Name: string;
Ports: string;
Replicas: string;
CurrentState: string;
DesiredState: string;
Error: string;
Node: string;
}
export const columns: ColumnDef<ApplicationList>[] = [
{
accessorKey: "ID",
accessorFn: (row) => row.ID,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
ID
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <div>{row.getValue("ID")}</div>;
},
},
{
accessorKey: "Name",
accessorFn: (row) => row.Name,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <div>{row.getValue("Name")}</div>;
},
},
{
accessorKey: "Image",
accessorFn: (row) => row.Image,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Image
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <div>{row.getValue("Image")}</div>;
},
},
{
accessorKey: "Mode",
accessorFn: (row) => row.Mode,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Mode
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <div>{row.getValue("Mode")}</div>;
},
},
{
accessorKey: "CurrentState",
accessorFn: (row) => row.CurrentState,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Current State
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const value = row.getValue("CurrentState") as string;
const valueStart = value.startsWith("Running")
? "Running"
: value.startsWith("Shutdown")
? "Shutdown"
: value;
return (
<div className="capitalize">
<Badge
variant={
valueStart === "Running"
? "default"
: value === "Shutdown"
? "destructive"
: "secondary"
}
>
{value}
</Badge>
</div>
);
},
},
{
accessorKey: "DesiredState",
accessorFn: (row) => row.DesiredState,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Desired State
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <div>{row.getValue("DesiredState")}</div>;
},
},
{
accessorKey: "Replicas",
accessorFn: (row) => row.Replicas,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Replicas
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <div>{row.getValue("Replicas")}</div>;
},
},
{
accessorKey: "Ports",
accessorFn: (row) => row.Ports,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Ports
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <div>{row.getValue("Ports")}</div>;
},
},
{
accessorKey: "Errors",
accessorFn: (row) => row.Error,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Errors
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <div>{row.getValue("Errors")}</div>;
},
},
];

View File

@@ -0,0 +1,197 @@
"use client";
import {
type ColumnDef,
type ColumnFiltersState,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
import { ChevronDown } from "lucide-react";
import React from "react";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[],
);
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState({});
const [pagination, setPagination] = React.useState({
pageIndex: 0, //initial page index
pageSize: 8, //default page size
});
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return (
<div className="mt-6 grid gap-4 pb-20 w-full">
<div className="flex flex-col gap-4 </div>w-full overflow-auto">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by name..."
value={(table.getColumn("Name")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("Name")?.setFilterValue(event.target.value)
}
className="md:max-w-sm"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="sm:ml-auto max-sm:w-full">
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table?.getRowModel()?.rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
{/* {isLoading ? (
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
<span className="text-muted-foreground text-lg font-medium">
Loading...
</span>
</div>
) : (
<>No results.</>
)} */}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{data && data?.length > 0 && (
<div className="flex items-center justify-end space-x-2 py-4">
<div className="space-x-2 flex flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { Layers, Loader2 } from "lucide-react";
import React from "react";
import { columns } from "./columns";
import { DataTable } from "./data-table";
interface Props {
serverId?: string;
}
interface ApplicationList {
ID: string;
Image: string;
Mode: string;
Name: string;
Ports: string;
Replicas: string;
CurrentState: string;
DesiredState: string;
Error: string;
Node: string;
}
export const ShowNodeApplications = ({ serverId }: Props) => {
const { data: NodeApps, isLoading: NodeAppsLoading } =
api.swarm.getNodeApps.useQuery({ serverId });
let applicationList = "";
if (NodeApps && NodeApps.length > 0) {
applicationList = NodeApps.map((app) => app.Name).join(" ");
}
const { data: NodeAppDetails, isLoading: NodeAppDetailsLoading } =
api.swarm.getAppInfos.useQuery({ appName: applicationList, serverId });
if (NodeAppsLoading || NodeAppDetailsLoading) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="w-full">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
</Button>
</DialogTrigger>
</Dialog>
);
}
if (!NodeApps || !NodeAppDetails) {
return (
<span className="text-sm w-full flex text-center justify-center items-center">
No data found
</span>
);
}
const combinedData: ApplicationList[] = NodeApps.flatMap((app) => {
const appDetails =
NodeAppDetails?.filter((detail) =>
detail.Name.startsWith(`${app.Name}.`),
) || [];
if (appDetails.length === 0) {
return [
{
...app,
CurrentState: "N/A",
DesiredState: "N/A",
Error: "",
Node: "N/A",
Ports: app.Ports,
},
];
}
return appDetails.map((detail) => ({
...app,
CurrentState: detail.CurrentState,
DesiredState: detail.DesiredState,
Error: detail.Error,
Node: detail.Node,
Ports: detail.Ports || app.Ports,
}));
});
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="w-full">
<Layers className="h-4 w-4 mr-2" />
Services
</Button>
</DialogTrigger>
<DialogContent className={"sm:max-w-6xl overflow-y-auto max-h-screen"}>
<DialogHeader>
<DialogTitle>Node Applications</DialogTitle>
<DialogDescription>
See in detail the applications running on this node
</DialogDescription>
</DialogHeader>
<div className="max-h-[80vh]">
<DataTable columns={columns} data={combinedData ?? []} />
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,140 @@
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { api } from "@/utils/api";
import {
AlertCircle,
CheckCircle,
HelpCircle,
Loader2,
LoaderIcon,
} from "lucide-react";
import { ShowNodeApplications } from "../applications/show-applications";
import { ShowNodeConfig } from "./show-node-config";
export interface SwarmList {
ID: string;
Hostname: string;
Availability: string;
EngineVersion: string;
Status: string;
ManagerStatus: string;
TLSStatus: string;
}
interface Props {
node: SwarmList;
serverId?: string;
}
export function NodeCard({ node, serverId }: Props) {
const { data, isLoading } = api.swarm.getNodeInfo.useQuery({
nodeId: node.ID,
serverId,
});
const getStatusIcon = (status: string) => {
switch (status) {
case "Ready":
return <CheckCircle className="h-4 w-4 text-green-500" />;
case "Down":
return <AlertCircle className="h-4 w-4 text-red-500" />;
default:
return <HelpCircle className="h-4 w-4 text-yellow-500" />;
}
};
if (isLoading) {
return (
<Card className="w-full bg-transparent">
<CardHeader>
<CardTitle className="flex items-center justify-between text-lg">
<span className="flex items-center gap-2">
{getStatusIcon(node.Status)}
{node.Hostname}
</span>
<Badge variant="outline" className="text-xs">
{node.ManagerStatus || "Worker"}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
</CardContent>
</Card>
);
}
return (
<Card className="w-full bg-transparent">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2 text-lg">
{getStatusIcon(node.Status)}
{node.Hostname}
</span>
<Badge variant="outline" className="text-xs">
{node.ManagerStatus || "Worker"}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-2 text-sm">
<div className="flex justify-between">
<span className="font-medium">Status:</span>
<span>{node.Status}</span>
</div>
<div className="flex justify-between">
<span className="font-medium">IP Address:</span>
{isLoading ? (
<LoaderIcon className="animate-spin" />
) : (
<span>{data?.Status?.Addr}</span>
)}
</div>
<div className="flex justify-between">
<span className="font-medium">Availability:</span>
<span>{node.Availability}</span>
</div>
<div className="flex justify-between">
<span className="font-medium">Engine Version:</span>
<span>{node.EngineVersion}</span>
</div>
<div className="flex justify-between">
<span className="font-medium">CPU:</span>
{isLoading ? (
<LoaderIcon className="animate-spin" />
) : (
<span>
{(data?.Description?.Resources?.NanoCPUs / 1e9).toFixed(2)} GHz
</span>
)}
</div>
<div className="flex justify-between">
<span className="font-medium">Memory:</span>
{isLoading ? (
<LoaderIcon className="animate-spin" />
) : (
<span>
{(
data?.Description?.Resources?.MemoryBytes /
1024 ** 3
).toFixed(2)}{" "}
GB
</span>
)}
</div>
<div className="flex justify-between">
<span className="font-medium">TLS Status:</span>
<span>{node.TLSStatus}</span>
</div>
</div>
<div className="flex gap-2 mt-4">
<ShowNodeConfig nodeId={node.ID} serverId={serverId} />
<ShowNodeApplications serverId={serverId} />
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,56 @@
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { Settings } from "lucide-react";
interface Props {
nodeId: string;
serverId?: string;
}
export const ShowNodeConfig = ({ nodeId, serverId }: Props) => {
const { data, isLoading } = api.swarm.getNodeInfo.useQuery({
nodeId,
serverId,
});
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="w-full">
<Settings className="h-4 w-4 mr-2" />
Config
</Button>
</DialogTrigger>
<DialogContent className={"sm:max-w-5xl overflow-y-auto max-h-screen"}>
<DialogHeader>
<DialogTitle>Node Config</DialogTitle>
<DialogDescription>
See in detail the metadata of this node
</DialogDescription>
</DialogHeader>
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem] bg-card max-h-[70vh] overflow-auto ">
<code>
<pre className="whitespace-pre-wrap break-words items-center justify-center">
{/* {JSON.stringify(data, null, 2)} */}
<CodeEditor
language="json"
lineWrapping={false}
lineNumbers={false}
readOnly
value={JSON.stringify(data, null, 2)}
/>
</pre>
</code>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,188 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import {
AlertCircle,
CheckCircle,
HelpCircle,
Loader2,
Server,
} from "lucide-react";
import { NodeCard } from "./details/details-card";
interface Props {
serverId?: string;
}
export default function SwarmMonitorCard({ serverId }: Props) {
const { data: nodes, isLoading } = api.swarm.getNodes.useQuery({
serverId,
});
if (isLoading) {
return (
<div className="w-full max-w-7xl mx-auto">
<div className="mb-6 border min-h-[55vh] rounded-lg h-full">
<div className="flex items-center justify-center h-full text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
</div>
</div>
);
}
if (!nodes) {
return (
<div className="w-full max-w-7xl mx-auto">
<div className="mb-6 border min-h-[55vh] rounded-lg h-full">
<div className="flex items-center justify-center h-full text-destructive">
<span>Failed to load data</span>
</div>
</div>
</div>
);
}
const totalNodes = nodes.length;
const activeNodesCount = nodes.filter(
(node) => node.Status === "Ready",
).length;
const managerNodesCount = nodes.filter(
(node) =>
node.ManagerStatus === "Leader" || node.ManagerStatus === "Reachable",
).length;
const activeNodes = nodes.filter((node) => node.Status === "Ready");
const managerNodes = nodes.filter(
(node) =>
node.ManagerStatus === "Leader" || node.ManagerStatus === "Reachable",
);
const getStatusIcon = (status: string) => {
switch (status) {
case "Ready":
return <CheckCircle className="h-4 w-4 text-green-500" />;
case "Down":
return <AlertCircle className="h-4 w-4 text-red-500" />;
case "Disconnected":
return <AlertCircle className="h-4 w-4 text-red-800" />;
default:
return <HelpCircle className="h-4 w-4 text-yellow-500" />;
}
};
return (
<div className="w-full max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-4">
<h1 className="text-xl font-bold">Docker Swarm Overview</h1>
{!serverId && (
<Button
type="button"
onClick={() =>
window.location.replace("/dashboard/settings/cluster")
}
>
Manage Cluster
</Button>
)}
</div>
<Card className="mb-6 bg-transparent">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2 text-xl">
<Server className="size-4" />
Monitor
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">Total Nodes:</span>
<Badge variant="secondary">{totalNodes}</Badge>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">Active Nodes:</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Badge
variant="secondary"
className="bg-green-100 dark:bg-green-400 text-black"
>
{activeNodesCount} / {totalNodes}
</Badge>
</TooltipTrigger>
<TooltipContent>
<div className="max-h-48 overflow-y-auto">
{activeNodes.map((node) => (
<div key={node.ID} className="flex items-center gap-2">
{getStatusIcon(node.Status)}
{node.Hostname}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">Manager Nodes:</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Badge
variant="secondary"
className="bg-blue-100 dark:bg-blue-400 text-black"
>
{managerNodesCount} / {totalNodes}
</Badge>
</TooltipTrigger>
<TooltipContent>
<div className="max-h-48 overflow-y-auto">
{managerNodes.map((node) => (
<div key={node.ID} className="flex items-center gap-2">
{getStatusIcon(node.Status)}
{node.Hostname}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className="border-t pt-4 mt-4">
<h4 className="text-sm font-semibold mb-2">Node Status:</h4>
<ul className="space-y-2">
{nodes.map((node) => (
<li
key={node.ID}
className="flex justify-between items-center text-sm"
>
<span className="flex items-center gap-2">
{getStatusIcon(node.Status)}
{node.Hostname}
</span>
<Badge variant="outline" className="text-xs">
{node.ManagerStatus || "Worker"}
</Badge>
</li>
))}
</ul>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{nodes.map((node) => (
<NodeCard key={node.ID} node={node} serverId={serverId} />
))}
</div>
</div>
);
}

View File

@@ -12,11 +12,16 @@ import { api } from "@/utils/api";
import { HeartIcon } from "lucide-react"; import { HeartIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react";
import { UpdateWebServer } from "../dashboard/settings/web-server/update-webserver";
import { Logo } from "../shared/logo"; import { Logo } from "../shared/logo";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { buttonVariants } from "../ui/button"; import { buttonVariants } from "../ui/button";
const AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 7;
export const Navbar = () => { export const Navbar = () => {
const [isUpdateAvailable, setIsUpdateAvailable] = useState<boolean>(false);
const router = useRouter(); const router = useRouter();
const { data } = api.auth.get.useQuery(); const { data } = api.auth.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -29,6 +34,59 @@ export const Navbar = () => {
}, },
); );
const { mutateAsync } = api.auth.logout.useMutation(); const { mutateAsync } = api.auth.logout.useMutation();
const { mutateAsync: getUpdateData } =
api.settings.getUpdateData.useMutation();
const checkUpdatesIntervalRef = useRef<null | NodeJS.Timeout>(null);
useEffect(() => {
// Handling of automatic check for server updates
if (isCloud) {
return;
}
if (!localStorage.getItem("enableAutoCheckUpdates")) {
// Enable auto update checking by default if user didn't change it
localStorage.setItem("enableAutoCheckUpdates", "true");
}
const clearUpdatesInterval = () => {
if (checkUpdatesIntervalRef.current) {
clearInterval(checkUpdatesIntervalRef.current);
}
};
const checkUpdates = async () => {
try {
if (localStorage.getItem("enableAutoCheckUpdates") !== "true") {
return;
}
const { updateAvailable } = await getUpdateData();
if (updateAvailable) {
// Stop interval when update is available
clearUpdatesInterval();
setIsUpdateAvailable(true);
}
} catch (error) {
console.error("Error auto-checking for updates:", error);
}
};
checkUpdatesIntervalRef.current = setInterval(
checkUpdates,
AUTO_CHECK_UPDATES_INTERVAL_MINUTES * 60000,
);
// Also check for updates on initial page load
checkUpdates();
return () => {
clearUpdatesInterval();
};
}, []);
return ( return (
<nav className="border-divider sticky inset-x-0 top-0 z-40 flex h-auto w-full items-center justify-center border-b bg-background/70 backdrop-blur-lg backdrop-saturate-150 data-[menu-open=true]:border-none data-[menu-open=true]:backdrop-blur-xl"> <nav className="border-divider sticky inset-x-0 top-0 z-40 flex h-auto w-full items-center justify-center border-b bg-background/70 backdrop-blur-lg backdrop-saturate-150 data-[menu-open=true]:border-none data-[menu-open=true]:backdrop-blur-xl">
<header className="relative z-40 flex w-full max-w-8xl flex-row flex-nowrap items-center justify-between gap-4 px-4 sm:px-6 h-16"> <header className="relative z-40 flex w-full max-w-8xl flex-row flex-nowrap items-center justify-between gap-4 px-4 sm:px-6 h-16">
@@ -43,6 +101,11 @@ export const Navbar = () => {
</span> </span>
</Link> </Link>
</div> </div>
{isUpdateAvailable && (
<div>
<UpdateWebServer isNavbar />
</div>
)}
<Link <Link
className={buttonVariants({ className={buttonVariants({
variant: "outline", variant: "outline",

View File

@@ -21,7 +21,8 @@ export type TabState =
| "settings" | "settings"
| "traefik" | "traefik"
| "requests" | "requests"
| "docker"; | "docker"
| "swarm";
const getTabMaps = (isCloud: boolean) => { const getTabMaps = (isCloud: boolean) => {
const elements: TabInfo[] = [ const elements: TabInfo[] = [
@@ -60,6 +61,15 @@ const getTabMaps = (isCloud: boolean) => {
}, },
type: "docker", type: "docker",
}, },
{
label: "Swarm",
description: "Manage your docker swarm and Servers",
index: "/dashboard/swarm",
isShow: ({ rol, user }) => {
return Boolean(rol === "admin" || user?.canAccessToDocker);
},
type: "swarm",
},
{ {
label: "Requests", label: "Requests",
description: "Manage your requests", description: "Manage your requests",

View File

@@ -62,6 +62,7 @@ export const Secrets = (props: Props) => {
} }
language="properties" language="properties"
disabled={isVisible} disabled={isVisible}
lineWrapping
placeholder={props.placeholder} placeholder={props.placeholder}
className="h-96 font-mono" className="h-96 font-mono"
{...field} {...field}

View File

@@ -0,0 +1 @@
ALTER TABLE "discord" ADD COLUMN "decoration" boolean;

File diff suppressed because it is too large Load Diff

View File

@@ -365,6 +365,13 @@
"when": 1734241482851, "when": 1734241482851,
"tag": "0051_hard_gorgon", "tag": "0051_hard_gorgon",
"breakpoints": true "breakpoints": true
},
{
"idx": 52,
"version": "6",
"when": 1734809337308,
"tag": "0052_bumpy_luckman",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "dokploy", "name": "dokploy",
"version": "v0.15.0", "version": "v0.15.1",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
@@ -35,8 +35,6 @@
"test": "vitest --config __test__/vitest.config.ts" "test": "vitest --config __test__/vitest.config.ts"
}, },
"dependencies": { "dependencies": {
"react-confetti-explosion":"2.1.2",
"@stepperize/react": "4.0.1",
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.1", "@codemirror/lang-yaml": "^6.1.1",
"@codemirror/language": "^6.10.1", "@codemirror/language": "^6.10.1",
@@ -64,6 +62,7 @@
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@stepperize/react": "4.0.1",
"@stripe/stripe-js": "4.8.0", "@stripe/stripe-js": "4.8.0",
"@tanstack/react-query": "^4.36.1", "@tanstack/react-query": "^4.36.1",
"@tanstack/react-table": "^8.16.0", "@tanstack/react-table": "^8.16.0",
@@ -87,6 +86,7 @@
"dotenv": "16.4.5", "dotenv": "16.4.5",
"drizzle-orm": "^0.30.8", "drizzle-orm": "^0.30.8",
"drizzle-zod": "0.5.1", "drizzle-zod": "0.5.1",
"fancy-ansi": "^0.1.3",
"i18next": "^23.16.4", "i18next": "^23.16.4",
"input-otp": "^1.2.4", "input-otp": "^1.2.4",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
@@ -104,6 +104,7 @@
"postgres": "3.4.4", "postgres": "3.4.4",
"public-ip": "6.0.2", "public-ip": "6.0.2",
"react": "18.2.0", "react": "18.2.0",
"react-confetti-explosion": "2.1.2",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "^7.49.3", "react-hook-form": "^7.49.3",
"react-i18next": "^15.1.0", "react-i18next": "^15.1.0",

View File

@@ -3,19 +3,19 @@ import { applications, compose, github } from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types"; import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup"; import { myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy"; import { deploy } from "@/server/utils/deploy";
import { generateRandomDomain } from "@/templates/utils";
import { import {
createPreviewDeployment,
type Domain, type Domain,
IS_CLOUD,
createPreviewDeployment,
findPreviewDeploymentByApplicationId, findPreviewDeploymentByApplicationId,
findPreviewDeploymentsByPullRequestId, findPreviewDeploymentsByPullRequestId,
IS_CLOUD,
removePreviewDeployment, removePreviewDeployment,
} from "@dokploy/server"; } from "@dokploy/server";
import { Webhooks } from "@octokit/webhooks"; import { Webhooks } from "@octokit/webhooks";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { extractCommitMessage, extractHash } from "./[refreshToken]"; import { extractCommitMessage, extractHash } from "./[refreshToken]";
import { generateRandomDomain } from "@/templates/utils";
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,

View File

@@ -0,0 +1,86 @@
import SwarmMonitorCard from "@/components/dashboard/swarm/monitoring-card";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { IS_CLOUD, validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
const Dashboard = () => {
return (
<>
<div className="flex flex-wrap gap-4 py-4">
<SwarmMonitorCard />
</div>
</>
);
};
export default Dashboard;
Dashboard.getLayout = (page: ReactElement) => {
return <DashboardLayout tab={"swarm"}>{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
if (IS_CLOUD) {
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
},
};
}
const { user, session } = await validateRequest(ctx.req, ctx.res);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
const { req, res } = ctx;
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
},
transformer: superjson,
});
try {
await helpers.project.all.prefetch();
const auth = await helpers.auth.get.fetch();
if (auth.rol === "user") {
const user = await helpers.user.byAuthId.fetch({
authId: auth.id,
});
if (!user.canAccessToDocker) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
}
return {
props: {
trpcState: helpers.dehydrate(),
},
};
} catch (error) {
return {
props: {},
};
}
}

View File

@@ -4,10 +4,10 @@
"settings.server.domain.description": "Add a domain to your server application.", "settings.server.domain.description": "Add a domain to your server application.",
"settings.server.domain.form.domain": "Domain", "settings.server.domain.form.domain": "Domain",
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt Email", "settings.server.domain.form.letsEncryptEmail": "Let's Encrypt Email",
"settings.server.domain.form.certificate.label": "Certificate", "settings.server.domain.form.certificate.label": "Certificate Provider",
"settings.server.domain.form.certificate.placeholder": "Select a certificate", "settings.server.domain.form.certificate.placeholder": "Select a certificate",
"settings.server.domain.form.certificateOptions.none": "None", "settings.server.domain.form.certificateOptions.none": "None",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (Default)", "settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
"settings.server.webServer.title": "Web Server", "settings.server.webServer.title": "Web Server",
"settings.server.webServer.description": "Reload or clean the web server.", "settings.server.webServer.description": "Reload or clean the web server.",
@@ -17,7 +17,15 @@
"settings.server.webServer.updateServerIp": "Update Server IP", "settings.server.webServer.updateServerIp": "Update Server IP",
"settings.server.webServer.server.label": "Server", "settings.server.webServer.server.label": "Server",
"settings.server.webServer.traefik.label": "Traefik", "settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Modify Env", "settings.server.webServer.traefik.modifyEnv": "Modify Environment",
"settings.server.webServer.traefik.managePorts": "Additional Port Mappings",
"settings.server.webServer.traefik.managePortsDescription": "Add or remove additional ports for Traefik",
"settings.server.webServer.traefik.targetPort": "Target Port",
"settings.server.webServer.traefik.publishedPort": "Published Port",
"settings.server.webServer.traefik.addPort": "Add Port",
"settings.server.webServer.traefik.portsUpdated": "Ports updated successfully",
"settings.server.webServer.traefik.portsUpdateError": "Failed to update ports",
"settings.server.webServer.traefik.publishMode": "Publish Mode",
"settings.server.webServer.storage.label": "Space", "settings.server.webServer.storage.label": "Space",
"settings.server.webServer.storage.cleanUnusedImages": "Clean unused images", "settings.server.webServer.storage.cleanUnusedImages": "Clean unused images",
"settings.server.webServer.storage.cleanUnusedVolumes": "Clean unused volumes", "settings.server.webServer.storage.cleanUnusedVolumes": "Clean unused volumes",

View File

@@ -21,6 +21,7 @@ import { mysqlRouter } from "./routers/mysql";
import { notificationRouter } from "./routers/notification"; import { notificationRouter } from "./routers/notification";
import { portRouter } from "./routers/port"; import { portRouter } from "./routers/port";
import { postgresRouter } from "./routers/postgres"; import { postgresRouter } from "./routers/postgres";
import { previewDeploymentRouter } from "./routers/preview-deployment";
import { projectRouter } from "./routers/project"; import { projectRouter } from "./routers/project";
import { redirectsRouter } from "./routers/redirects"; import { redirectsRouter } from "./routers/redirects";
import { redisRouter } from "./routers/redis"; import { redisRouter } from "./routers/redis";
@@ -30,8 +31,8 @@ import { serverRouter } from "./routers/server";
import { settingsRouter } from "./routers/settings"; import { settingsRouter } from "./routers/settings";
import { sshRouter } from "./routers/ssh-key"; import { sshRouter } from "./routers/ssh-key";
import { stripeRouter } from "./routers/stripe"; import { stripeRouter } from "./routers/stripe";
import { swarmRouter } from "./routers/swarm";
import { userRouter } from "./routers/user"; import { userRouter } from "./routers/user";
import { previewDeploymentRouter } from "./routers/preview-deployment";
/** /**
* This is the primary router for your server. * This is the primary router for your server.
@@ -73,6 +74,7 @@ export const appRouter = createTRPCRouter({
github: githubRouter, github: githubRouter,
server: serverRouter, server: serverRouter,
stripe: stripeRouter, stripe: stripeRouter,
swarm: swarmRouter,
}); });
// export type definition of API // export type definition of API

View File

@@ -188,9 +188,9 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const currentAuth = await findAuthByEmail(ctx.user.email); const currentAuth = await findAuthByEmail(ctx.user.email);
if (input.password) { if (input.currentPassword || input.password) {
const correctPassword = bcrypt.compareSync( const correctPassword = bcrypt.compareSync(
input.password, input.currentPassword || "",
currentAuth?.password || "", currentAuth?.password || "",
); );
if (!correctPassword) { if (!correctPassword) {
@@ -268,7 +268,9 @@ export const authRouter = createTRPCRouter({
return auth; return auth;
}), }),
verifyToken: protectedProcedure.mutation(async () => {
return true;
}),
one: adminProcedure.input(apiFindOneAuth).query(async ({ input }) => { one: adminProcedure.input(apiFindOneAuth).query(async ({ input }) => {
const auth = await findAuthById(input.id); const auth = await findAuthById(input.id);
return auth; return auth;

View File

@@ -3,6 +3,7 @@ import { db } from "@/server/db";
import { import {
apiCreateCompose, apiCreateCompose,
apiCreateComposeByTemplate, apiCreateComposeByTemplate,
apiDeleteCompose,
apiFetchServices, apiFetchServices,
apiFindCompose, apiFindCompose,
apiRandomizeCompose, apiRandomizeCompose,
@@ -117,7 +118,7 @@ export const composeRouter = createTRPCRouter({
return updateCompose(input.composeId, input); return updateCompose(input.composeId, input);
}), }),
delete: protectedProcedure delete: protectedProcedure
.input(apiFindCompose) .input(apiDeleteCompose)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
if (ctx.user.rol === "user") { if (ctx.user.rol === "user") {
await checkServiceAccess(ctx.user.authId, input.composeId, "delete"); await checkServiceAccess(ctx.user.authId, input.composeId, "delete");
@@ -138,7 +139,7 @@ export const composeRouter = createTRPCRouter({
.returning(); .returning();
const cleanupOperations = [ const cleanupOperations = [
async () => await removeCompose(composeResult), async () => await removeCompose(composeResult, input.deleteVolumes),
async () => await removeDeploymentsByComposeId(composeResult), async () => await removeDeploymentsByComposeId(composeResult),
async () => await removeComposeDirectory(composeResult.appName), async () => await removeComposeDirectory(composeResult.appName),
]; ];

View File

@@ -187,11 +187,15 @@ export const notificationRouter = createTRPCRouter({
.input(apiTestDiscordConnection) .input(apiTestDiscordConnection)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const decorate = (decoration: string, text: string) =>
`${input.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(input, { await sendDiscordNotification(input, {
title: "> `🤚` - Test Notification", title: decorate(">", "`🤚` - Test Notification"),
description: "> Hi, From Dokploy 👋", description: decorate(">", "Hi, From Dokploy 👋"),
color: 0xf3f7f4, color: 0xf3f7f4,
}); });
return true; return true;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({

View File

@@ -12,6 +12,7 @@ import {
} from "@/server/db/schema"; } from "@/server/db/schema";
import { removeJob, schedule } from "@/server/utils/backup"; import { removeJob, schedule } from "@/server/utils/backup";
import { import {
DEFAULT_UPDATE_DATA,
IS_CLOUD, IS_CLOUD,
canAccessToTraefikFiles, canAccessToTraefikFiles,
cleanStoppedContainers, cleanStoppedContainers,
@@ -25,6 +26,8 @@ import {
findAdminById, findAdminById,
findServerById, findServerById,
getDokployImage, getDokployImage,
getDokployImageTag,
getUpdateData,
initializeTraefik, initializeTraefik,
logRotationManager, logRotationManager,
parseRawConfig, parseRawConfig,
@@ -267,11 +270,11 @@ export const settingsRouter = createTRPCRouter({
message: "You are not authorized to access this admin", message: "You are not authorized to access this admin",
}); });
} }
await updateAdmin(ctx.user.authId, { const adminUpdated = await updateAdmin(ctx.user.authId, {
enableDockerCleanup: input.enableDockerCleanup, enableDockerCleanup: input.enableDockerCleanup,
}); });
if (admin.enableDockerCleanup) { if (adminUpdated?.enableDockerCleanup) {
scheduleJob("docker-cleanup", "0 0 * * *", async () => { scheduleJob("docker-cleanup", "0 0 * * *", async () => {
console.log( console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running...`, `Docker Cleanup ${new Date().toLocaleString()}] Running...`,
@@ -342,17 +345,20 @@ export const settingsRouter = createTRPCRouter({
writeConfig("middlewares", input.traefikConfig); writeConfig("middlewares", input.traefikConfig);
return true; return true;
}), }),
getUpdateData: adminProcedure.mutation(async () => {
checkAndUpdateImage: adminProcedure.mutation(async () => {
if (IS_CLOUD) { if (IS_CLOUD) {
return true; return DEFAULT_UPDATE_DATA;
} }
return await pullLatestRelease();
return await getUpdateData();
}), }),
updateServer: adminProcedure.mutation(async () => { updateServer: adminProcedure.mutation(async () => {
if (IS_CLOUD) { if (IS_CLOUD) {
return true; return true;
} }
await pullLatestRelease();
await spawnAsync("docker", [ await spawnAsync("docker", [
"service", "service",
"update", "update",
@@ -361,12 +367,16 @@ export const settingsRouter = createTRPCRouter({
getDokployImage(), getDokployImage(),
"dokploy", "dokploy",
]); ]);
return true; return true;
}), }),
getDokployVersion: adminProcedure.query(() => { getDokployVersion: adminProcedure.query(() => {
return packageInfo.version; return packageInfo.version;
}), }),
getReleaseTag: adminProcedure.query(() => {
return getDokployImageTag();
}),
readDirectories: protectedProcedure readDirectories: protectedProcedure
.input(apiServerSchema) .input(apiServerSchema)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
@@ -706,6 +716,83 @@ export const settingsRouter = createTRPCRouter({
throw new Error("Failed to check GPU status"); throw new Error("Failed to check GPU status");
} }
}), }),
updateTraefikPorts: adminProcedure
.input(
z.object({
serverId: z.string().optional(),
additionalPorts: z.array(
z.object({
targetPort: z.number(),
publishedPort: z.number(),
publishMode: z.enum(["ingress", "host"]).default("host"),
}),
),
}),
)
.mutation(async ({ input }) => {
try {
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Please set a serverId to update Traefik ports",
});
}
await initializeTraefik({
serverId: input.serverId,
additionalPorts: input.additionalPorts,
});
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Error to update Traefik ports",
cause: error,
});
}
}),
getTraefikPorts: adminProcedure
.input(apiServerSchema)
.query(async ({ input }) => {
const command = `docker service inspect --format='{{json .Endpoint.Ports}}' dokploy-traefik`;
try {
let stdout = "";
if (input?.serverId) {
const result = await execAsyncRemote(input.serverId, command);
stdout = result.stdout;
} else if (!IS_CLOUD) {
const result = await execAsync(command);
stdout = result.stdout;
}
const ports: {
Protocol: string;
TargetPort: number;
PublishedPort: number;
PublishMode: string;
}[] = JSON.parse(stdout.trim());
// Filter out the default ports (80, 443, and optionally 8080)
const additionalPorts = ports
.filter((port) => ![80, 443, 8080].includes(port.PublishedPort))
.map((port) => ({
targetPort: port.TargetPort,
publishedPort: port.PublishedPort,
publishMode: port.PublishMode.toLowerCase() as "host" | "ingress",
}));
return additionalPorts;
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to get Traefik ports",
cause: error,
});
}
}),
}); });
// { // {
// "Parallelism": 1, // "Parallelism": 1,

View File

@@ -0,0 +1,44 @@
import {
getApplicationInfo,
getNodeApplications,
getNodeInfo,
getSwarmNodes,
} from "@dokploy/server";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const swarmRouter = createTRPCRouter({
getNodes: protectedProcedure
.input(
z.object({
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
return await getSwarmNodes(input.serverId);
}),
getNodeInfo: protectedProcedure
.input(z.object({ nodeId: z.string(), serverId: z.string().optional() }))
.query(async ({ input }) => {
return await getNodeInfo(input.nodeId, input.serverId);
}),
getNodeApps: protectedProcedure
.input(
z.object({
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
return getNodeApplications(input.serverId);
}),
getAppInfos: protectedProcedure
.input(
z.object({
appName: z.string(),
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
return await getApplicationInfo(input.appName, input.serverId);
}),
});

View File

@@ -87,7 +87,7 @@ const config = {
}, },
}, },
}, },
plugins: [require("tailwindcss-animate")], plugins: [require("tailwindcss-animate"), require("fancy-ansi/plugin")],
} satisfies Config; } satisfies Config;
export default config; export default config;

View File

@@ -26,7 +26,7 @@ services:
hard: 262144 hard: 262144
plausible: plausible:
image: ghcr.io/plausible/community-edition:v2.1.0 image: ghcr.io/plausible/community-edition:v2.1.4
restart: always restart: always
command: sh -c "sleep 10 && /entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh run" command: sh -c "sleep 10 && /entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh run"
depends_on: depends_on:

View File

@@ -34,7 +34,7 @@ export const templates: TemplateData[] = [
{ {
id: "plausible", id: "plausible",
name: "Plausible", name: "Plausible",
version: "v2.1.0", version: "v2.1.4",
description: description:
"Plausible is a open source, self-hosted web analytics platform that lets you track website traffic and user behavior.", "Plausible is a open source, self-hosted web analytics platform that lets you track website traffic and user behavior.",
logo: "plausible.svg", logo: "plausible.svg",

View File

@@ -1,10 +1,10 @@
import { import {
type DomainSchema,
type Schema,
type Template,
generateBase64,
generateHash, generateHash,
generateRandomDomain, generateRandomDomain,
generateBase64,
type Template,
type Schema,
type DomainSchema,
} from "../utils"; } from "../utils";
export function generate(schema: Schema): Template { export function generate(schema: Schema): Template {

View File

@@ -5,34 +5,22 @@ export const IS_CLOUD = process.env.IS_CLOUD === "true";
export const docker = new Docker(); export const docker = new Docker();
export const paths = (isServer = false) => { export const paths = (isServer = false) => {
if (isServer) {
const BASE_PATH = "/etc/dokploy";
return {
BASE_PATH,
MAIN_TRAEFIK_PATH: `${BASE_PATH}/traefik`,
DYNAMIC_TRAEFIK_PATH: `${BASE_PATH}/traefik/dynamic`,
LOGS_PATH: `${BASE_PATH}/logs`,
APPLICATIONS_PATH: `${BASE_PATH}/applications`,
COMPOSE_PATH: `${BASE_PATH}/compose`,
SSH_PATH: `${BASE_PATH}/ssh`,
CERTIFICATES_PATH: `${BASE_PATH}/certificates`,
MONITORING_PATH: `${BASE_PATH}/monitoring`,
REGISTRY_PATH: `${BASE_PATH}/registry`,
};
}
const BASE_PATH = const BASE_PATH =
process.env.NODE_ENV === "production" isServer || process.env.NODE_ENV === "production"
? "/etc/dokploy" ? "/etc/dokploy"
: path.join(process.cwd(), ".docker"); : path.join(process.cwd(), ".docker");
const MAIN_TRAEFIK_PATH = `${BASE_PATH}/traefik`;
const DYNAMIC_TRAEFIK_PATH = `${MAIN_TRAEFIK_PATH}/dynamic`;
return { return {
BASE_PATH, BASE_PATH,
MAIN_TRAEFIK_PATH: `${BASE_PATH}/traefik`, MAIN_TRAEFIK_PATH,
DYNAMIC_TRAEFIK_PATH: `${BASE_PATH}/traefik/dynamic`, DYNAMIC_TRAEFIK_PATH,
LOGS_PATH: `${BASE_PATH}/logs`, LOGS_PATH: `${BASE_PATH}/logs`,
APPLICATIONS_PATH: `${BASE_PATH}/applications`, APPLICATIONS_PATH: `${BASE_PATH}/applications`,
COMPOSE_PATH: `${BASE_PATH}/compose`, COMPOSE_PATH: `${BASE_PATH}/compose`,
SSH_PATH: `${BASE_PATH}/ssh`, SSH_PATH: `${BASE_PATH}/ssh`,
CERTIFICATES_PATH: `${BASE_PATH}/certificates`, CERTIFICATES_PATH: `${DYNAMIC_TRAEFIK_PATH}/certificates`,
MONITORING_PATH: `${BASE_PATH}/monitoring`, MONITORING_PATH: `${BASE_PATH}/monitoring`,
REGISTRY_PATH: `${BASE_PATH}/registry`, REGISTRY_PATH: `${BASE_PATH}/registry`,
}; };

View File

@@ -17,6 +17,7 @@ import { github } from "./github";
import { gitlab } from "./gitlab"; import { gitlab } from "./gitlab";
import { mounts } from "./mount"; import { mounts } from "./mount";
import { ports } from "./port"; import { ports } from "./port";
import { previewDeployments } from "./preview-deployments";
import { projects } from "./project"; import { projects } from "./project";
import { redirects } from "./redirects"; import { redirects } from "./redirects";
import { registry } from "./registry"; import { registry } from "./registry";
@@ -25,7 +26,6 @@ import { server } from "./server";
import { applicationStatus, certificateType } from "./shared"; import { applicationStatus, certificateType } from "./shared";
import { sshKeys } from "./ssh-key"; import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils"; import { generateAppName } from "./utils";
import { previewDeployments } from "./preview-deployments";
export const sourceType = pgEnum("sourceType", [ export const sourceType = pgEnum("sourceType", [
"docker", "docker",

View File

@@ -155,6 +155,11 @@ export const apiFindCompose = z.object({
composeId: z.string().min(1), composeId: z.string().min(1),
}); });
export const apiDeleteCompose = z.object({
composeId: z.string().min(1),
deleteVolumes: z.boolean(),
});
export const apiFetchServices = z.object({ export const apiFetchServices = z.object({
composeId: z.string().min(1), composeId: z.string().min(1),
type: z.enum(["fetch", "cache"]).optional().default("cache"), type: z.enum(["fetch", "cache"]).optional().default("cache"),

View File

@@ -11,8 +11,8 @@ import { nanoid } from "nanoid";
import { z } from "zod"; import { z } from "zod";
import { applications } from "./application"; import { applications } from "./application";
import { compose } from "./compose"; import { compose } from "./compose";
import { server } from "./server";
import { previewDeployments } from "./preview-deployments"; import { previewDeployments } from "./preview-deployments";
import { server } from "./server";
export const deploymentStatus = pgEnum("deploymentStatus", [ export const deploymentStatus = pgEnum("deploymentStatus", [
"running", "running",

View File

@@ -14,8 +14,8 @@ import { z } from "zod";
import { domain } from "../validations/domain"; import { domain } from "../validations/domain";
import { applications } from "./application"; import { applications } from "./application";
import { compose } from "./compose"; import { compose } from "./compose";
import { certificateType } from "./shared";
import { previewDeployments } from "./preview-deployments"; import { previewDeployments } from "./preview-deployments";
import { certificateType } from "./shared";
export const domainType = pgEnum("domainType", [ export const domainType = pgEnum("domainType", [
"compose", "compose",

View File

@@ -68,6 +68,7 @@ export const discord = pgTable("discord", {
.primaryKey() .primaryKey()
.$defaultFn(() => nanoid()), .$defaultFn(() => nanoid()),
webhookUrl: text("webhookUrl").notNull(), webhookUrl: text("webhookUrl").notNull(),
decoration: boolean("decoration"),
}); });
export const email = pgTable("email", { export const email = pgTable("email", {
@@ -171,6 +172,7 @@ export const apiCreateDiscord = notificationsSchema
}) })
.extend({ .extend({
webhookUrl: z.string().min(1), webhookUrl: z.string().min(1),
decoration: z.boolean(),
}) })
.required(); .required();
@@ -180,8 +182,12 @@ export const apiUpdateDiscord = apiCreateDiscord.partial().extend({
adminId: z.string().optional(), adminId: z.string().optional(),
}); });
export const apiTestDiscordConnection = apiCreateDiscord.pick({ export const apiTestDiscordConnection = apiCreateDiscord
.pick({
webhookUrl: true, webhookUrl: true,
})
.extend({
decoration: z.boolean().optional(),
}); });
export const apiCreateEmail = notificationsSchema export const apiCreateEmail = notificationsSchema

View File

@@ -1,13 +1,13 @@
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { pgTable, text } from "drizzle-orm/pg-core"; import { pgTable, text } from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { applications } from "./application";
import { domains } from "./domain";
import { deployments } from "./deployment";
import { createInsertSchema } from "drizzle-zod"; import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod"; import { z } from "zod";
import { generateAppName } from "./utils"; import { applications } from "./application";
import { deployments } from "./deployment";
import { domains } from "./domain";
import { applicationStatus } from "./shared"; import { applicationStatus } from "./shared";
import { generateAppName } from "./utils";
export const previewDeployments = pgTable("preview_deployments", { export const previewDeployments = pgTable("preview_deployments", {
previewDeploymentId: text("previewDeploymentId") previewDeploymentId: text("previewDeploymentId")

View File

@@ -1,3 +1,4 @@
import { generatePassword } from "@dokploy/server/templates/utils";
import { faker } from "@faker-js/faker"; import { faker } from "@faker-js/faker";
import { customAlphabet } from "nanoid"; import { customAlphabet } from "nanoid";
@@ -13,3 +14,17 @@ export const generateAppName = (type: string) => {
const nanoidPart = customNanoid(); const nanoidPart = customNanoid();
return `${type}-${randomFakerElement}-${nanoidPart}`; return `${type}-${randomFakerElement}-${nanoidPart}`;
}; };
export const cleanAppName = (appName?: string) => {
if (!appName) {
return appName?.toLowerCase();
}
return appName.trim().replace(/ /g, "-").toLowerCase();
};
export const buildAppName = (type: string, baseAppName?: string) => {
if (baseAppName) {
return `${cleanAppName(baseAppName)}-${generatePassword(6)}`;
}
return generateAppName(type);
};

View File

@@ -3,10 +3,10 @@ import { db } from "@dokploy/server/db";
import { import {
type apiCreateApplication, type apiCreateApplication,
applications, applications,
buildAppName,
cleanAppName,
} from "@dokploy/server/db/schema"; } from "@dokploy/server/db/schema";
import { generateAppName } from "@dokploy/server/db/schema";
import { getAdvancedStats } from "@dokploy/server/monitoring/utilts"; import { getAdvancedStats } from "@dokploy/server/monitoring/utilts";
import { generatePassword } from "@dokploy/server/templates/utils";
import { import {
buildApplication, buildApplication,
getBuildCommand, getBuildCommand,
@@ -46,41 +46,39 @@ import {
createDeploymentPreview, createDeploymentPreview,
updateDeploymentStatus, updateDeploymentStatus,
} from "./deployment"; } from "./deployment";
import { validUniqueServerAppName } from "./project"; import { type Domain, getDomainHost } from "./domain";
import {
findPreviewDeploymentById,
updatePreviewDeployment,
} from "./preview-deployment";
import { import {
createPreviewDeploymentComment, createPreviewDeploymentComment,
getIssueComment, getIssueComment,
issueCommentExists, issueCommentExists,
updateIssueComment, updateIssueComment,
} from "./github"; } from "./github";
import { type Domain, getDomainHost } from "./domain"; import {
findPreviewDeploymentById,
updatePreviewDeployment,
} from "./preview-deployment";
import { validUniqueServerAppName } from "./project";
export type Application = typeof applications.$inferSelect; export type Application = typeof applications.$inferSelect;
export const createApplication = async ( export const createApplication = async (
input: typeof apiCreateApplication._type, input: typeof apiCreateApplication._type,
) => { ) => {
input.appName = const appName = buildAppName("app", input.appName);
`${input.appName}-${generatePassword(6)}` || generateAppName("app");
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
const valid = await validUniqueServerAppName(appName);
if (!valid) { if (!valid) {
throw new TRPCError({ throw new TRPCError({
code: "CONFLICT", code: "CONFLICT",
message: "Application with this 'AppName' already exists", message: "Application with this 'AppName' already exists",
}); });
} }
}
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
const newApplication = await tx const newApplication = await tx
.insert(applications) .insert(applications)
.values({ .values({
...input, ...input,
appName,
}) })
.returning() .returning()
.then((value) => value[0]); .then((value) => value[0]);
@@ -140,10 +138,11 @@ export const updateApplication = async (
applicationId: string, applicationId: string,
applicationData: Partial<Application>, applicationData: Partial<Application>,
) => { ) => {
const { appName, ...rest } = applicationData;
const application = await db const application = await db
.update(applications) .update(applications)
.set({ .set({
...applicationData, ...rest,
}) })
.where(eq(applications.applicationId, applicationId)) .where(eq(applications.applicationId, applicationId))
.returning(); .returning();

View File

@@ -2,7 +2,7 @@ import { join } from "node:path";
import { paths } from "@dokploy/server/constants"; import { paths } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { type apiCreateCompose, compose } from "@dokploy/server/db/schema"; import { type apiCreateCompose, compose } from "@dokploy/server/db/schema";
import { generateAppName } from "@dokploy/server/db/schema"; import { buildAppName, cleanAppName } from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates/utils"; import { generatePassword } from "@dokploy/server/templates/utils";
import { import {
buildCompose, buildCompose,
@@ -52,24 +52,22 @@ import { validUniqueServerAppName } from "./project";
export type Compose = typeof compose.$inferSelect; export type Compose = typeof compose.$inferSelect;
export const createCompose = async (input: typeof apiCreateCompose._type) => { export const createCompose = async (input: typeof apiCreateCompose._type) => {
input.appName = const appName = buildAppName("compose", input.appName);
`${input.appName}-${generatePassword(6)}` || generateAppName("compose");
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
const valid = await validUniqueServerAppName(appName);
if (!valid) { if (!valid) {
throw new TRPCError({ throw new TRPCError({
code: "CONFLICT", code: "CONFLICT",
message: "Service with this 'AppName' already exists", message: "Service with this 'AppName' already exists",
}); });
} }
}
const newDestination = await db const newDestination = await db
.insert(compose) .insert(compose)
.values({ .values({
...input, ...input,
composeFile: "", composeFile: "",
appName,
}) })
.returning() .returning()
.then((value) => value[0]); .then((value) => value[0]);
@@ -87,8 +85,9 @@ export const createCompose = async (input: typeof apiCreateCompose._type) => {
export const createComposeByTemplate = async ( export const createComposeByTemplate = async (
input: typeof compose.$inferInsert, input: typeof compose.$inferInsert,
) => { ) => {
if (input.appName) { const appName = cleanAppName(input.appName);
const valid = await validUniqueServerAppName(input.appName); if (appName) {
const valid = await validUniqueServerAppName(appName);
if (!valid) { if (!valid) {
throw new TRPCError({ throw new TRPCError({
@@ -101,6 +100,7 @@ export const createComposeByTemplate = async (
.insert(compose) .insert(compose)
.values({ .values({
...input, ...input,
appName,
}) })
.returning() .returning()
.then((value) => value[0]); .then((value) => value[0]);
@@ -184,10 +184,11 @@ export const updateCompose = async (
composeId: string, composeId: string,
composeData: Partial<Compose>, composeData: Partial<Compose>,
) => { ) => {
const { appName, ...rest } = composeData;
const composeResult = await db const composeResult = await db
.update(compose) .update(compose)
.set({ .set({
...composeData, ...rest,
}) })
.where(eq(compose.composeId, composeId)) .where(eq(compose.composeId, composeId))
.returning(); .returning();
@@ -205,7 +206,9 @@ export const deployCompose = async ({
descriptionLog: string; descriptionLog: string;
}) => { }) => {
const compose = await findComposeById(composeId); const compose = await findComposeById(composeId);
const buildLink = `${await getDokployUrl()}/dashboard/project/${compose.projectId}/services/compose/${compose.composeId}?tab=deployments`; const buildLink = `${await getDokployUrl()}/dashboard/project/${
compose.projectId
}/services/compose/${compose.composeId}?tab=deployments`;
const deployment = await createDeploymentCompose({ const deployment = await createDeploymentCompose({
composeId: composeId, composeId: composeId,
title: titleLog, title: titleLog,
@@ -307,7 +310,9 @@ export const deployRemoteCompose = async ({
descriptionLog: string; descriptionLog: string;
}) => { }) => {
const compose = await findComposeById(composeId); const compose = await findComposeById(composeId);
const buildLink = `${await getDokployUrl()}/dashboard/project/${compose.projectId}/services/compose/${compose.composeId}?tab=deployments`; const buildLink = `${await getDokployUrl()}/dashboard/project/${
compose.projectId
}/services/compose/${compose.composeId}?tab=deployments`;
const deployment = await createDeploymentCompose({ const deployment = await createDeploymentCompose({
composeId: composeId, composeId: composeId,
title: titleLog, title: titleLog,
@@ -436,13 +441,17 @@ export const rebuildRemoteCompose = async ({
return true; return true;
}; };
export const removeCompose = async (compose: Compose) => { export const removeCompose = async (
compose: Compose,
deleteVolumes: boolean,
) => {
try { try {
const { COMPOSE_PATH } = paths(!!compose.serverId); const { COMPOSE_PATH } = paths(!!compose.serverId);
const projectPath = join(COMPOSE_PATH, compose.appName); const projectPath = join(COMPOSE_PATH, compose.appName);
if (compose.composeType === "stack") { if (compose.composeType === "stack") {
const command = `cd ${projectPath} && docker stack rm ${compose.appName} && rm -rf ${projectPath}`; const command = `cd ${projectPath} && docker stack rm ${compose.appName} && rm -rf ${projectPath}`;
if (compose.serverId) { if (compose.serverId) {
await execAsyncRemote(compose.serverId, command); await execAsyncRemote(compose.serverId, command);
} else { } else {
@@ -452,7 +461,13 @@ export const removeCompose = async (compose: Compose) => {
cwd: projectPath, cwd: projectPath,
}); });
} else { } else {
const command = `cd ${projectPath} && docker compose -p ${compose.appName} down && rm -rf ${projectPath}`; let command: string;
if (deleteVolumes) {
command = `cd ${projectPath} && docker compose -p ${compose.appName} down --volumes && rm -rf ${projectPath}`;
} else {
command = `cd ${projectPath} && docker compose -p ${compose.appName} down && rm -rf ${projectPath}`;
}
if (compose.serverId) { if (compose.serverId) {
await execAsyncRemote(compose.serverId, command); await execAsyncRemote(compose.serverId, command);
} else { } else {
@@ -476,7 +491,11 @@ export const startCompose = async (composeId: string) => {
if (compose.serverId) { if (compose.serverId) {
await execAsyncRemote( await execAsyncRemote(
compose.serverId, compose.serverId,
`cd ${join(COMPOSE_PATH, compose.appName, "code")} && docker compose -p ${compose.appName} up -d`, `cd ${join(
COMPOSE_PATH,
compose.appName,
"code",
)} && docker compose -p ${compose.appName} up -d`,
); );
} else { } else {
await execAsync(`docker compose -p ${compose.appName} up -d`, { await execAsync(`docker compose -p ${compose.appName} up -d`, {
@@ -506,7 +525,9 @@ export const stopCompose = async (composeId: string) => {
if (compose.serverId) { if (compose.serverId) {
await execAsyncRemote( await execAsyncRemote(
compose.serverId, compose.serverId,
`cd ${join(COMPOSE_PATH, compose.appName)} && docker compose -p ${compose.appName} stop`, `cd ${join(COMPOSE_PATH, compose.appName)} && docker compose -p ${
compose.appName
} stop`,
); );
} else { } else {
await execAsync(`docker compose -p ${compose.appName} stop`, { await execAsync(`docker compose -p ${compose.appName} stop`, {

View File

@@ -23,8 +23,8 @@ import { type Server, findServerById } from "./server";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { import {
findPreviewDeploymentById,
type PreviewDeployment, type PreviewDeployment,
findPreviewDeploymentById,
updatePreviewDeployment, updatePreviewDeployment,
} from "./preview-deployment"; } from "./preview-deployment";

View File

@@ -342,3 +342,124 @@ export const containerRestart = async (containerId: string) => {
return config; return config;
} catch (error) {} } catch (error) {}
}; };
export const getSwarmNodes = async (serverId?: string) => {
try {
let stdout = "";
let stderr = "";
const command = "docker node ls --format '{{json .}}'";
if (serverId) {
const result = await execAsyncRemote(serverId, command);
stdout = result.stdout;
stderr = result.stderr;
} else {
const result = await execAsync(command);
stdout = result.stdout;
stderr = result.stderr;
}
if (stderr) {
console.error(`Error: ${stderr}`);
return;
}
const nodes = JSON.parse(stdout);
const nodesArray = stdout
.trim()
.split("\n")
.map((line) => JSON.parse(line));
return nodesArray;
} catch (error) {}
};
export const getNodeInfo = async (nodeId: string, serverId?: string) => {
try {
const command = `docker node inspect ${nodeId} --format '{{json .}}'`;
let stdout = "";
let stderr = "";
if (serverId) {
const result = await execAsyncRemote(serverId, command);
stdout = result.stdout;
stderr = result.stderr;
} else {
const result = await execAsync(command);
stdout = result.stdout;
stderr = result.stderr;
}
if (stderr) {
console.error(`Error: ${stderr}`);
return;
}
const nodeInfo = JSON.parse(stdout);
return nodeInfo;
} catch (error) {}
};
export const getNodeApplications = async (serverId?: string) => {
try {
let stdout = "";
let stderr = "";
const command = `docker service ls --format '{{json .}}'`;
if (serverId) {
const result = await execAsyncRemote(serverId, command);
stdout = result.stdout;
stderr = result.stderr;
} else {
const result = await execAsync(command);
stdout = result.stdout;
stderr = result.stderr;
}
if (stderr) {
console.error(`Error: ${stderr}`);
return;
}
const appArray = stdout
.trim()
.split("\n")
.map((line) => JSON.parse(line));
return appArray;
} catch (error) {}
};
export const getApplicationInfo = async (
appName: string,
serverId?: string,
) => {
try {
let stdout = "";
let stderr = "";
const command = `docker service ps ${appName} --format '{{json .}}'`;
if (serverId) {
const result = await execAsyncRemote(serverId, command);
stdout = result.stdout;
stderr = result.stderr;
} else {
const result = await execAsync(command);
stdout = result.stdout;
stderr = result.stderr;
}
if (stderr) {
console.error(`Error: ${stderr}`);
return;
}
const appArray = stdout
.trim()
.split("\n")
.map((line) => JSON.parse(line));
return appArray;
} catch (error) {}
};

View File

@@ -4,7 +4,7 @@ import {
backups, backups,
mariadb, mariadb,
} from "@dokploy/server/db/schema"; } from "@dokploy/server/db/schema";
import { generateAppName } from "@dokploy/server/db/schema"; import { buildAppName, cleanAppName } from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates/utils"; import { generatePassword } from "@dokploy/server/templates/utils";
import { buildMariadb } from "@dokploy/server/utils/databases/mariadb"; import { buildMariadb } from "@dokploy/server/utils/databases/mariadb";
import { pullImage } from "@dokploy/server/utils/docker/utils"; import { pullImage } from "@dokploy/server/utils/docker/utils";
@@ -17,18 +17,15 @@ import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
export type Mariadb = typeof mariadb.$inferSelect; export type Mariadb = typeof mariadb.$inferSelect;
export const createMariadb = async (input: typeof apiCreateMariaDB._type) => { export const createMariadb = async (input: typeof apiCreateMariaDB._type) => {
input.appName = const appName = buildAppName("mariadb", input.appName);
`${input.appName}-${generatePassword(6)}` || generateAppName("mariadb");
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
const valid = await validUniqueServerAppName(input.appName);
if (!valid) { if (!valid) {
throw new TRPCError({ throw new TRPCError({
code: "CONFLICT", code: "CONFLICT",
message: "Service with this 'AppName' already exists", message: "Service with this 'AppName' already exists",
}); });
} }
}
const newMariadb = await db const newMariadb = await db
.insert(mariadb) .insert(mariadb)
@@ -40,6 +37,7 @@ export const createMariadb = async (input: typeof apiCreateMariaDB._type) => {
databaseRootPassword: input.databaseRootPassword databaseRootPassword: input.databaseRootPassword
? input.databaseRootPassword ? input.databaseRootPassword
: generatePassword(), : generatePassword(),
appName,
}) })
.returning() .returning()
.then((value) => value[0]); .then((value) => value[0]);
@@ -82,10 +80,11 @@ export const updateMariadbById = async (
mariadbId: string, mariadbId: string,
mariadbData: Partial<Mariadb>, mariadbData: Partial<Mariadb>,
) => { ) => {
const { appName, ...rest } = mariadbData;
const result = await db const result = await db
.update(mariadb) .update(mariadb)
.set({ .set({
...mariadbData, ...rest,
}) })
.where(eq(mariadb.mariadbId, mariadbId)) .where(eq(mariadb.mariadbId, mariadbId))
.returning(); .returning();

View File

@@ -1,6 +1,6 @@
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { type apiCreateMongo, backups, mongo } from "@dokploy/server/db/schema"; import { type apiCreateMongo, backups, mongo } from "@dokploy/server/db/schema";
import { generateAppName } from "@dokploy/server/db/schema"; import { buildAppName, cleanAppName } from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates/utils"; import { generatePassword } from "@dokploy/server/templates/utils";
import { buildMongo } from "@dokploy/server/utils/databases/mongo"; import { buildMongo } from "@dokploy/server/utils/databases/mongo";
import { pullImage } from "@dokploy/server/utils/docker/utils"; import { pullImage } from "@dokploy/server/utils/docker/utils";
@@ -13,18 +13,15 @@ import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
export type Mongo = typeof mongo.$inferSelect; export type Mongo = typeof mongo.$inferSelect;
export const createMongo = async (input: typeof apiCreateMongo._type) => { export const createMongo = async (input: typeof apiCreateMongo._type) => {
input.appName = const appName = buildAppName("mongo", input.appName);
`${input.appName}-${generatePassword(6)}` || generateAppName("mongo");
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
const valid = await validUniqueServerAppName(appName);
if (!valid) { if (!valid) {
throw new TRPCError({ throw new TRPCError({
code: "CONFLICT", code: "CONFLICT",
message: "Service with this 'AppName' already exists", message: "Service with this 'AppName' already exists",
}); });
} }
}
const newMongo = await db const newMongo = await db
.insert(mongo) .insert(mongo)
@@ -33,6 +30,7 @@ export const createMongo = async (input: typeof apiCreateMongo._type) => {
databasePassword: input.databasePassword databasePassword: input.databasePassword
? input.databasePassword ? input.databasePassword
: generatePassword(), : generatePassword(),
appName,
}) })
.returning() .returning()
.then((value) => value[0]); .then((value) => value[0]);
@@ -74,10 +72,11 @@ export const updateMongoById = async (
mongoId: string, mongoId: string,
mongoData: Partial<Mongo>, mongoData: Partial<Mongo>,
) => { ) => {
const { appName, ...rest } = mongoData;
const result = await db const result = await db
.update(mongo) .update(mongo)
.set({ .set({
...mongoData, ...rest,
}) })
.where(eq(mongo.mongoId, mongoId)) .where(eq(mongo.mongoId, mongoId))
.returning(); .returning();

View File

@@ -1,6 +1,6 @@
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { type apiCreateMySql, backups, mysql } from "@dokploy/server/db/schema"; import { type apiCreateMySql, backups, mysql } from "@dokploy/server/db/schema";
import { generateAppName } from "@dokploy/server/db/schema"; import { buildAppName, cleanAppName } from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates/utils"; import { generatePassword } from "@dokploy/server/templates/utils";
import { buildMysql } from "@dokploy/server/utils/databases/mysql"; import { buildMysql } from "@dokploy/server/utils/databases/mysql";
import { pullImage } from "@dokploy/server/utils/docker/utils"; import { pullImage } from "@dokploy/server/utils/docker/utils";
@@ -13,19 +13,15 @@ import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
export type MySql = typeof mysql.$inferSelect; export type MySql = typeof mysql.$inferSelect;
export const createMysql = async (input: typeof apiCreateMySql._type) => { export const createMysql = async (input: typeof apiCreateMySql._type) => {
input.appName = const appName = buildAppName("mysql", input.appName);
`${input.appName}-${generatePassword(6)}` || generateAppName("mysql");
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
const valid = await validUniqueServerAppName(appName);
if (!valid) { if (!valid) {
throw new TRPCError({ throw new TRPCError({
code: "CONFLICT", code: "CONFLICT",
message: "Service with this 'AppName' already exists", message: "Service with this 'AppName' already exists",
}); });
} }
}
const newMysql = await db const newMysql = await db
.insert(mysql) .insert(mysql)
@@ -37,6 +33,7 @@ export const createMysql = async (input: typeof apiCreateMySql._type) => {
databaseRootPassword: input.databaseRootPassword databaseRootPassword: input.databaseRootPassword
? input.databaseRootPassword ? input.databaseRootPassword
: generatePassword(), : generatePassword(),
appName,
}) })
.returning() .returning()
.then((value) => value[0]); .then((value) => value[0]);
@@ -79,10 +76,11 @@ export const updateMySqlById = async (
mysqlId: string, mysqlId: string,
mysqlData: Partial<MySql>, mysqlData: Partial<MySql>,
) => { ) => {
const { appName, ...rest } = mysqlData;
const result = await db const result = await db
.update(mysql) .update(mysql)
.set({ .set({
...mysqlData, ...rest,
}) })
.where(eq(mysql.mysqlId, mysqlId)) .where(eq(mysql.mysqlId, mysqlId))
.returning(); .returning();

View File

@@ -204,6 +204,7 @@ export const createDiscordNotification = async (
.insert(discord) .insert(discord)
.values({ .values({
webhookUrl: input.webhookUrl, webhookUrl: input.webhookUrl,
decoration: input.decoration,
}) })
.returning() .returning()
.then((value) => value[0]); .then((value) => value[0]);
@@ -272,6 +273,7 @@ export const updateDiscordNotification = async (
.update(discord) .update(discord)
.set({ .set({
webhookUrl: input.webhookUrl, webhookUrl: input.webhookUrl,
decoration: input.decoration,
}) })
.where(eq(discord.discordId, input.discordId)) .where(eq(discord.discordId, input.discordId))
.returning() .returning()

View File

@@ -4,7 +4,7 @@ import {
backups, backups,
postgres, postgres,
} from "@dokploy/server/db/schema"; } from "@dokploy/server/db/schema";
import { generateAppName } from "@dokploy/server/db/schema"; import { buildAppName, cleanAppName } from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates/utils"; import { generatePassword } from "@dokploy/server/templates/utils";
import { buildPostgres } from "@dokploy/server/utils/databases/postgres"; import { buildPostgres } from "@dokploy/server/utils/databases/postgres";
import { pullImage } from "@dokploy/server/utils/docker/utils"; import { pullImage } from "@dokploy/server/utils/docker/utils";
@@ -17,18 +17,15 @@ import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
export type Postgres = typeof postgres.$inferSelect; export type Postgres = typeof postgres.$inferSelect;
export const createPostgres = async (input: typeof apiCreatePostgres._type) => { export const createPostgres = async (input: typeof apiCreatePostgres._type) => {
input.appName = const appName = buildAppName("postgres", input.appName);
`${input.appName}-${generatePassword(6)}` || generateAppName("postgres");
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
const valid = await validUniqueServerAppName(appName);
if (!valid) { if (!valid) {
throw new TRPCError({ throw new TRPCError({
code: "CONFLICT", code: "CONFLICT",
message: "Service with this 'AppName' already exists", message: "Service with this 'AppName' already exists",
}); });
} }
}
const newPostgres = await db const newPostgres = await db
.insert(postgres) .insert(postgres)
@@ -37,6 +34,7 @@ export const createPostgres = async (input: typeof apiCreatePostgres._type) => {
databasePassword: input.databasePassword databasePassword: input.databasePassword
? input.databasePassword ? input.databasePassword
: generatePassword(), : generatePassword(),
appName,
}) })
.returning() .returning()
.then((value) => value[0]); .then((value) => value[0]);
@@ -96,10 +94,11 @@ export const updatePostgresById = async (
postgresId: string, postgresId: string,
postgresData: Partial<Postgres>, postgresData: Partial<Postgres>,
) => { ) => {
const { appName, ...rest } = postgresData;
const result = await db const result = await db
.update(postgres) .update(postgres)
.set({ .set({
...postgresData, ...rest,
}) })
.where(eq(postgres.postgresId, postgresId)) .where(eq(postgres.postgresId, postgresId))
.returning(); .returning();

View File

@@ -7,20 +7,20 @@ import {
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { and, desc, eq } from "drizzle-orm"; import { and, desc, eq } from "drizzle-orm";
import { slugify } from "../setup/server-setup"; import { slugify } from "../setup/server-setup";
import { findApplicationById } from "./application";
import { createDomain } from "./domain";
import { generatePassword, generateRandomDomain } from "../templates/utils"; import { generatePassword, generateRandomDomain } from "../templates/utils";
import { removeService } from "../utils/docker/utils";
import { removeDirectoryCode } from "../utils/filesystem/directory";
import { authGithub } from "../utils/providers/github";
import { removeTraefikConfig } from "../utils/traefik/application";
import { manageDomain } from "../utils/traefik/domain"; import { manageDomain } from "../utils/traefik/domain";
import { findAdminById } from "./admin";
import { findApplicationById } from "./application";
import { import {
removeDeployments, removeDeployments,
removeDeploymentsByPreviewDeploymentId, removeDeploymentsByPreviewDeploymentId,
} from "./deployment"; } from "./deployment";
import { removeDirectoryCode } from "../utils/filesystem/directory"; import { createDomain } from "./domain";
import { removeTraefikConfig } from "../utils/traefik/application"; import { type Github, getIssueComment } from "./github";
import { removeService } from "../utils/docker/utils";
import { authGithub } from "../utils/providers/github";
import { getIssueComment, type Github } from "./github";
import { findAdminById } from "./admin";
export type PreviewDeployment = typeof previewDeployments.$inferSelect; export type PreviewDeployment = typeof previewDeployments.$inferSelect;

View File

@@ -1,6 +1,6 @@
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { type apiCreateRedis, redis } from "@dokploy/server/db/schema"; import { type apiCreateRedis, redis } from "@dokploy/server/db/schema";
import { generateAppName } from "@dokploy/server/db/schema"; import { buildAppName, cleanAppName } from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates/utils"; import { generatePassword } from "@dokploy/server/templates/utils";
import { buildRedis } from "@dokploy/server/utils/databases/redis"; import { buildRedis } from "@dokploy/server/utils/databases/redis";
import { pullImage } from "@dokploy/server/utils/docker/utils"; import { pullImage } from "@dokploy/server/utils/docker/utils";
@@ -14,18 +14,15 @@ export type Redis = typeof redis.$inferSelect;
// https://github.com/drizzle-team/drizzle-orm/discussions/1483#discussioncomment-7523881 // https://github.com/drizzle-team/drizzle-orm/discussions/1483#discussioncomment-7523881
export const createRedis = async (input: typeof apiCreateRedis._type) => { export const createRedis = async (input: typeof apiCreateRedis._type) => {
input.appName = const appName = buildAppName("redis", input.appName);
`${input.appName}-${generatePassword(6)}` || generateAppName("redis");
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
const valid = await validUniqueServerAppName(appName);
if (!valid) { if (!valid) {
throw new TRPCError({ throw new TRPCError({
code: "CONFLICT", code: "CONFLICT",
message: "Service with this 'AppName' already exists", message: "Service with this 'AppName' already exists",
}); });
} }
}
const newRedis = await db const newRedis = await db
.insert(redis) .insert(redis)
@@ -34,6 +31,7 @@ export const createRedis = async (input: typeof apiCreateRedis._type) => {
databasePassword: input.databasePassword databasePassword: input.databasePassword
? input.databasePassword ? input.databasePassword
: generatePassword(), : generatePassword(),
appName,
}) })
.returning() .returning()
.then((value) => value[0]); .then((value) => value[0]);
@@ -70,10 +68,11 @@ export const updateRedisById = async (
redisId: string, redisId: string,
redisData: Partial<Redis>, redisData: Partial<Redis>,
) => { ) => {
const { appName, ...rest } = redisData;
const result = await db const result = await db
.update(redis) .update(redis)
.set({ .set({
...redisData, ...rest,
}) })
.where(eq(redis.redisId, redisId)) .where(eq(redis.redisId, redisId))
.returning(); .returning();

View File

@@ -1,41 +1,108 @@
import { readdirSync } from "node:fs"; import { readdirSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { docker } from "@dokploy/server/constants"; import { docker } from "@dokploy/server/constants";
import { getServiceContainer } from "@dokploy/server/utils/docker/utils"; import {
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
// import packageInfo from "../../../package.json"; // import packageInfo from "../../../package.json";
const updateIsAvailable = async () => { export interface IUpdateData {
try { latestVersion: string | null;
const service = await getServiceContainer("dokploy"); updateAvailable: boolean;
const localImage = await docker.getImage(getDokployImage()).inspect();
return localImage.Id !== service?.ImageID;
} catch (error) {
return false;
} }
export const DEFAULT_UPDATE_DATA: IUpdateData = {
latestVersion: null,
updateAvailable: false,
};
/** Returns current Dokploy docker image tag or `latest` by default. */
export const getDokployImageTag = () => {
return process.env.RELEASE_TAG || "latest";
}; };
export const getDokployImage = () => { export const getDokployImage = () => {
return `dokploy/dokploy:${process.env.RELEASE_TAG || "latest"}`; return `dokploy/dokploy:${getDokployImageTag()}`;
}; };
export const pullLatestRelease = async () => { export const pullLatestRelease = async () => {
try { const stream = await docker.pull(getDokployImage());
const stream = await docker.pull(getDokployImage(), {});
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
docker.modem.followProgress(stream, (err, res) => docker.modem.followProgress(stream, (err, res) =>
err ? reject(err) : resolve(res), err ? reject(err) : resolve(res),
); );
}); });
const newUpdateIsAvailable = await updateIsAvailable();
return newUpdateIsAvailable;
} catch (error) {}
return false;
}; };
export const getDokployVersion = () => {
// return packageInfo.version; /** Returns Dokploy docker service image digest */
export const getServiceImageDigest = async () => {
const { stdout } = await execAsync(
"docker service inspect dokploy --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}'",
);
const currentDigest = stdout.trim().split("@")[1];
if (!currentDigest) {
throw new Error("Could not get current service image digest");
}
return currentDigest;
};
/** Returns latest version number and information whether server update is available by comparing current image's digest against digest for provided image tag via Docker hub API. */
export const getUpdateData = async (): Promise<IUpdateData> => {
let currentDigest: string;
try {
currentDigest = await getServiceImageDigest();
} catch {
// Docker service might not exist locally
// You can run the # Installation command for docker service create mentioned in the below docs to test it locally:
// https://docs.dokploy.com/docs/core/manual-installation
return DEFAULT_UPDATE_DATA;
}
const baseUrl = "https://hub.docker.com/v2/repositories/dokploy/dokploy/tags";
let url: string | null = `${baseUrl}?page_size=100`;
let allResults: { digest: string; name: string }[] = [];
while (url) {
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
const data = (await response.json()) as {
next: string | null;
results: { digest: string; name: string }[];
};
allResults = allResults.concat(data.results);
url = data?.next;
}
const imageTag = getDokployImageTag();
const searchedDigest = allResults.find((t) => t.name === imageTag)?.digest;
if (!searchedDigest) {
return DEFAULT_UPDATE_DATA;
}
if (imageTag === "latest") {
const versionedTag = allResults.find(
(t) => t.digest === searchedDigest && t.name.startsWith("v"),
);
if (!versionedTag) {
return DEFAULT_UPDATE_DATA;
}
const { name: latestVersion, digest } = versionedTag;
const updateAvailable = digest !== currentDigest;
return { latestVersion, updateAvailable };
}
const updateAvailable = searchedDigest !== currentDigest;
return { latestVersion: imageTag, updateAvailable };
}; };
interface TreeDataItem { interface TreeDataItem {

View File

@@ -7,6 +7,9 @@ import {
} from "@dokploy/server/services/deployment"; } from "@dokploy/server/services/deployment";
import { findServerById } from "@dokploy/server/services/server"; import { findServerById } from "@dokploy/server/services/server";
import { import {
TRAEFIK_PORT,
TRAEFIK_SSL_PORT,
TRAEFIK_VERSION,
getDefaultMiddlewares, getDefaultMiddlewares,
getDefaultServerTraefikConfig, getDefaultServerTraefikConfig,
} from "@dokploy/server/setup/traefik-setup"; } from "@dokploy/server/setup/traefik-setup";
@@ -510,7 +513,7 @@ export const createTraefikInstance = () => {
echo "Traefik already exists ✅" echo "Traefik already exists ✅"
else else
# Create the dokploy-traefik service # Create the dokploy-traefik service
TRAEFIK_VERSION=3.1.2 TRAEFIK_VERSION=${TRAEFIK_VERSION}
docker service create \ docker service create \
--name dokploy-traefik \ --name dokploy-traefik \
--replicas 1 \ --replicas 1 \
@@ -520,8 +523,8 @@ export const createTraefikInstance = () => {
--mount type=bind,src=/etc/dokploy/traefik/dynamic,dst=/etc/dokploy/traefik/dynamic \ --mount type=bind,src=/etc/dokploy/traefik/dynamic,dst=/etc/dokploy/traefik/dynamic \
--mount type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock \ --mount type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock \
--label traefik.enable=true \ --label traefik.enable=true \
--publish mode=host,target=443,published=443 \ --publish mode=host,target=${TRAEFIK_SSL_PORT},published=${TRAEFIK_SSL_PORT} \
--publish mode=host,target=80,published=80 \ --publish mode=host,target=${TRAEFIK_PORT},published=${TRAEFIK_PORT} \
traefik:v$TRAEFIK_VERSION traefik:v$TRAEFIK_VERSION
echo "Traefik version $TRAEFIK_VERSION installed ✅" echo "Traefik version $TRAEFIK_VERSION installed ✅"
fi fi

View File

@@ -8,23 +8,31 @@ import { getRemoteDocker } from "../utils/servers/remote-docker";
import type { FileConfig } from "../utils/traefik/file-types"; import type { FileConfig } from "../utils/traefik/file-types";
import type { MainTraefikConfig } from "../utils/traefik/types"; import type { MainTraefikConfig } from "../utils/traefik/types";
const TRAEFIK_SSL_PORT = export const TRAEFIK_SSL_PORT =
Number.parseInt(process.env.TRAEFIK_SSL_PORT!, 10) || 443; Number.parseInt(process.env.TRAEFIK_SSL_PORT!, 10) || 443;
const TRAEFIK_PORT = Number.parseInt(process.env.TRAEFIK_PORT!, 10) || 80; export const TRAEFIK_PORT =
Number.parseInt(process.env.TRAEFIK_PORT!, 10) || 80;
export const TRAEFIK_VERSION = process.env.TRAEFIK_VERSION || "3.1.2";
interface TraefikOptions { interface TraefikOptions {
enableDashboard?: boolean; enableDashboard?: boolean;
env?: string[]; env?: string[];
serverId?: string; serverId?: string;
additionalPorts?: {
targetPort: number;
publishedPort: number;
publishMode?: "ingress" | "host";
}[];
} }
export const initializeTraefik = async ({ export const initializeTraefik = async ({
enableDashboard = false, enableDashboard = false,
env, env,
serverId, serverId,
additionalPorts = [],
}: TraefikOptions = {}) => { }: TraefikOptions = {}) => {
const { MAIN_TRAEFIK_PATH, DYNAMIC_TRAEFIK_PATH } = paths(!!serverId); const { MAIN_TRAEFIK_PATH, DYNAMIC_TRAEFIK_PATH } = paths(!!serverId);
const imageName = "traefik:v3.1.2"; const imageName = `traefik:v${TRAEFIK_VERSION}`;
const containerName = "dokploy-traefik"; const containerName = "dokploy-traefik";
const settings: CreateServiceOptions = { const settings: CreateServiceOptions = {
Name: containerName, Name: containerName,
@@ -84,6 +92,11 @@ export const initializeTraefik = async ({
}, },
] ]
: []), : []),
...additionalPorts.map((port) => ({
TargetPort: port.targetPort,
PublishedPort: port.publishedPort,
PublishMode: port.publishMode || ("host" as const),
})),
], ],
}, },
}; };

View File

@@ -7,6 +7,8 @@ import {
cleanUpSystemPrune, cleanUpSystemPrune,
cleanUpUnusedImages, cleanUpUnusedImages,
} from "../docker/utils"; } from "../docker/utils";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup";
import { runMariadbBackup } from "./mariadb"; import { runMariadbBackup } from "./mariadb";
import { runMongoBackup } from "./mongo"; import { runMongoBackup } from "./mongo";
import { runMySqlBackup } from "./mysql"; import { runMySqlBackup } from "./mysql";
@@ -25,14 +27,15 @@ export const initCronJobs = async () => {
await cleanUpUnusedImages(); await cleanUpUnusedImages();
await cleanUpDockerBuilder(); await cleanUpDockerBuilder();
await cleanUpSystemPrune(); await cleanUpSystemPrune();
await sendDockerCleanupNotifications(admin.adminId);
}); });
} }
const servers = await getAllServers(); const servers = await getAllServers();
for (const server of servers) { for (const server of servers) {
const { appName, serverId } = server; const { appName, serverId, enableDockerCleanup } = server;
if (serverId) { if (enableDockerCleanup) {
scheduleJob(serverId, "0 0 * * *", async () => { scheduleJob(serverId, "0 0 * * *", async () => {
console.log( console.log(
`SERVER-BACKUP[${new Date().toLocaleString()}] Running Cleanup ${appName}`, `SERVER-BACKUP[${new Date().toLocaleString()}] Running Cleanup ${appName}`,
@@ -40,12 +43,17 @@ export const initCronJobs = async () => {
await cleanUpUnusedImages(serverId); await cleanUpUnusedImages(serverId);
await cleanUpDockerBuilder(serverId); await cleanUpDockerBuilder(serverId);
await cleanUpSystemPrune(serverId); await cleanUpSystemPrune(serverId);
await sendDockerCleanupNotifications(
admin.adminId,
`Docker cleanup for Server ${appName}`,
);
}); });
} }
} }
const pgs = await db.query.postgres.findMany({ const pgs = await db.query.postgres.findMany({
with: { with: {
project: true,
backups: { backups: {
with: { with: {
destination: true, destination: true,
@@ -61,18 +69,39 @@ export const initCronJobs = async () => {
for (const backup of pg.backups) { for (const backup of pg.backups) {
const { schedule, backupId, enabled } = backup; const { schedule, backupId, enabled } = backup;
if (enabled) { if (enabled) {
try {
scheduleJob(backupId, schedule, async () => { scheduleJob(backupId, schedule, async () => {
console.log( console.log(
`PG-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`, `PG-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`,
); );
runPostgresBackup(pg, backup); runPostgresBackup(pg, backup);
}); });
await sendDatabaseBackupNotifications({
applicationName: pg.name,
projectName: pg.project.name,
databaseType: "postgres",
type: "success",
adminId: pg.project.adminId,
});
} catch (error) {
await sendDatabaseBackupNotifications({
applicationName: pg.name,
projectName: pg.project.name,
databaseType: "postgres",
type: "error",
// @ts-ignore
errorMessage: error?.message || "Error message not provided",
adminId: pg.project.adminId,
});
}
} }
} }
} }
const mariadbs = await db.query.mariadb.findMany({ const mariadbs = await db.query.mariadb.findMany({
with: { with: {
project: true,
backups: { backups: {
with: { with: {
destination: true, destination: true,
@@ -89,18 +118,38 @@ export const initCronJobs = async () => {
for (const backup of maria.backups) { for (const backup of maria.backups) {
const { schedule, backupId, enabled } = backup; const { schedule, backupId, enabled } = backup;
if (enabled) { if (enabled) {
try {
scheduleJob(backupId, schedule, async () => { scheduleJob(backupId, schedule, async () => {
console.log( console.log(
`MARIADB-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`, `MARIADB-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`,
); );
await runMariadbBackup(maria, backup); await runMariadbBackup(maria, backup);
}); });
await sendDatabaseBackupNotifications({
applicationName: maria.name,
projectName: maria.project.name,
databaseType: "mariadb",
type: "success",
adminId: maria.project.adminId,
});
} catch (error) {
await sendDatabaseBackupNotifications({
applicationName: maria.name,
projectName: maria.project.name,
databaseType: "mariadb",
type: "error",
// @ts-ignore
errorMessage: error?.message || "Error message not provided",
adminId: maria.project.adminId,
});
}
} }
} }
} }
const mongodbs = await db.query.mongo.findMany({ const mongodbs = await db.query.mongo.findMany({
with: { with: {
project: true,
backups: { backups: {
with: { with: {
destination: true, destination: true,
@@ -117,18 +166,38 @@ export const initCronJobs = async () => {
for (const backup of mongo.backups) { for (const backup of mongo.backups) {
const { schedule, backupId, enabled } = backup; const { schedule, backupId, enabled } = backup;
if (enabled) { if (enabled) {
try {
scheduleJob(backupId, schedule, async () => { scheduleJob(backupId, schedule, async () => {
console.log( console.log(
`MONGO-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`, `MONGO-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`,
); );
await runMongoBackup(mongo, backup); await runMongoBackup(mongo, backup);
}); });
await sendDatabaseBackupNotifications({
applicationName: mongo.name,
projectName: mongo.project.name,
databaseType: "mongodb",
type: "success",
adminId: mongo.project.adminId,
});
} catch (error) {
await sendDatabaseBackupNotifications({
applicationName: mongo.name,
projectName: mongo.project.name,
databaseType: "mongodb",
type: "error",
// @ts-ignore
errorMessage: error?.message || "Error message not provided",
adminId: mongo.project.adminId,
});
}
} }
} }
} }
const mysqls = await db.query.mysql.findMany({ const mysqls = await db.query.mysql.findMany({
with: { with: {
project: true,
backups: { backups: {
with: { with: {
destination: true, destination: true,
@@ -145,12 +214,31 @@ export const initCronJobs = async () => {
for (const backup of mysql.backups) { for (const backup of mysql.backups) {
const { schedule, backupId, enabled } = backup; const { schedule, backupId, enabled } = backup;
if (enabled) { if (enabled) {
try {
scheduleJob(backupId, schedule, async () => { scheduleJob(backupId, schedule, async () => {
console.log( console.log(
`MYSQL-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`, `MYSQL-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`,
); );
await runMySqlBackup(mysql, backup); await runMySqlBackup(mysql, backup);
}); });
await sendDatabaseBackupNotifications({
applicationName: mysql.name,
projectName: mysql.project.name,
databaseType: "mysql",
type: "success",
adminId: mysql.project.adminId,
});
} catch (error) {
await sendDatabaseBackupNotifications({
applicationName: mysql.name,
projectName: mysql.project.name,
databaseType: "mysql",
type: "error",
// @ts-ignore
errorMessage: error?.message || "Error message not provided",
adminId: mysql.project.adminId,
});
}
} }
} }
} }

View File

@@ -48,6 +48,7 @@ Compose Type: ${composeType} ✅`;
writeStream.write(`\n${logBox}\n`); writeStream.write(`\n${logBox}\n`);
const projectPath = join(COMPOSE_PATH, compose.appName, "code"); const projectPath = join(COMPOSE_PATH, compose.appName, "code");
await spawnAsync( await spawnAsync(
"docker", "docker",
[...command.split(" ")], [...command.split(" ")],
@@ -67,7 +68,7 @@ Compose Type: ${composeType} ✅`;
writeStream.write("Docker Compose Deployed: ✅"); writeStream.write("Docker Compose Deployed: ✅");
} catch (error) { } catch (error) {
writeStream.write("Error ❌"); writeStream.write(`Error ❌ ${(error as Error).message}`);
throw error; throw error;
} finally { } finally {
writeStream.end(); writeStream.end();
@@ -144,6 +145,10 @@ const sanitizeCommand = (command: string) => {
export const createCommand = (compose: ComposeNested) => { export const createCommand = (compose: ComposeNested) => {
const { composeType, appName, sourceType } = compose; const { composeType, appName, sourceType } = compose;
if (compose.command) {
return `${sanitizeCommand(compose.command)}`;
}
const path = const path =
sourceType === "raw" ? "docker-compose.yml" : compose.composePath; sourceType === "raw" ? "docker-compose.yml" : compose.composePath;
let command = ""; let command = "";
@@ -154,12 +159,6 @@ export const createCommand = (compose: ComposeNested) => {
command = `stack deploy -c ${path} ${appName} --prune`; command = `stack deploy -c ${path} ${appName} --prune`;
} }
const customCommand = sanitizeCommand(compose.command);
if (customCommand) {
command = `${command} ${customCommand}`;
}
return command; return command;
}; };

View File

@@ -2,6 +2,7 @@ import { createWriteStream } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import type { InferResultType } from "@dokploy/server/types/with"; import type { InferResultType } from "@dokploy/server/types/with";
import type { CreateServiceOptions } from "dockerode"; import type { CreateServiceOptions } from "dockerode";
import { nanoid } from "nanoid";
import { uploadImage, uploadImageRemoteCommand } from "../cluster/upload"; import { uploadImage, uploadImageRemoteCommand } from "../cluster/upload";
import { import {
calculateResources, calculateResources,
@@ -17,7 +18,6 @@ import { buildHeroku, getHerokuCommand } from "./heroku";
import { buildNixpacks, getNixpacksCommand } from "./nixpacks"; import { buildNixpacks, getNixpacksCommand } from "./nixpacks";
import { buildPaketo, getPaketoCommand } from "./paketo"; import { buildPaketo, getPaketoCommand } from "./paketo";
import { buildStatic, getStaticCommand } from "./static"; import { buildStatic, getStaticCommand } from "./static";
import { nanoid } from "nanoid";
// NIXPACKS codeDirectory = where is the path of the code directory // NIXPACKS codeDirectory = where is the path of the code directory
// HEROKU codeDirectory = where is the path of the code directory // HEROKU codeDirectory = where is the path of the code directory
@@ -211,21 +211,21 @@ const getImageName = (application: ApplicationNested) => {
} }
if (registry) { if (registry) {
return join(registry.imagePrefix || "", appName); return join(registry.registryUrl, registry.imagePrefix || "", appName);
} }
return `${appName}:latest`; return `${appName}:latest`;
}; };
const getAuthConfig = (application: ApplicationNested) => { const getAuthConfig = (application: ApplicationNested) => {
const { registry, username, password, sourceType } = application; const { registry, username, password, sourceType, registryUrl } = application;
if (sourceType === "docker") { if (sourceType === "docker") {
if (username && password) { if (username && password) {
return { return {
password, password,
username, username,
serveraddress: "https://index.docker.io/v1/", serveraddress: registryUrl || "",
}; };
} }
} else if (registry) { } else if (registry) {

View File

@@ -1,5 +1,5 @@
import type { WriteStream } from "node:fs"; import type { WriteStream } from "node:fs";
import { join } from "node:path"; import path, { join } from "node:path";
import type { ApplicationNested } from "../builders"; import type { ApplicationNested } from "../builders";
import { spawnAsync } from "../process/spawnAsync"; import { spawnAsync } from "../process/spawnAsync";
@@ -13,27 +13,32 @@ export const uploadImage = async (
throw new Error("Registry not found"); throw new Error("Registry not found");
} }
const { registryUrl, imagePrefix, registryType } = registry; const { registryUrl, imagePrefix } = registry;
const { appName } = application; const { appName } = application;
const imageName = `${appName}:latest`; const imageName = `${appName}:latest`;
const finalURL = registryUrl; const finalURL = registryUrl;
const registryTag = join(imagePrefix || "", imageName); const registryTag = path
.join(registryUrl, join(imagePrefix || "", imageName))
.replace(/\/+/g, "/");
try { try {
writeStream.write( writeStream.write(
`📦 [Enabled Registry] Uploading image to ${registry.registryType} | ${registryTag} | ${finalURL}\n`, `📦 [Enabled Registry] Uploading image to ${registry.registryType} | ${imageName} | ${finalURL}\n`,
); );
await spawnAsync( const loginCommand = spawnAsync(
"docker", "docker",
["login", finalURL, "-u", registry.username, "-p", registry.password], ["login", finalURL, "-u", registry.username, "--password-stdin"],
(data) => { (data) => {
if (writeStream.writable) { if (writeStream.writable) {
writeStream.write(data); writeStream.write(data);
} }
}, },
); );
loginCommand.child?.stdin?.write(registry.password);
loginCommand.child?.stdin?.end();
await loginCommand;
await spawnAsync("docker", ["tag", imageName, registryTag], (data) => { await spawnAsync("docker", ["tag", imageName, registryTag], (data) => {
if (writeStream.writable) { if (writeStream.writable) {
@@ -68,22 +73,23 @@ export const uploadImageRemoteCommand = (
const finalURL = registryUrl; const finalURL = registryUrl;
const registryTag = join(imagePrefix || "", imageName); const registryTag = path
.join(registryUrl, join(imagePrefix || "", imageName))
.replace(/\/+/g, "/");
try { try {
const command = ` const command = `
echo "📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'" >> ${logPath}; echo "📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'" >> ${logPath};
docker login ${finalURL} -u ${registry.username} -p ${registry.password} >> ${logPath} 2>> ${logPath} || { echo "${registry.password}" | docker login ${finalURL} -u ${registry.username} --password-stdin >> ${logPath} 2>> ${logPath} || {
echo "❌ DockerHub Failed" >> ${logPath}; echo "❌ DockerHub Failed" >> ${logPath};
exit 1; exit 1;
} }
echo "✅ DockerHub Login Success" >> ${logPath}; echo "✅ Registry Login Success" >> ${logPath};
docker tag ${imageName} ${registryTag} >> ${logPath} 2>> ${logPath} || { docker tag ${imageName} ${registryTag} >> ${logPath} 2>> ${logPath} || {
echo "❌ Error tagging image" >> ${logPath}; echo "❌ Error tagging image" >> ${logPath};
exit 1; exit 1;
} }
echo "✅ Image Tagged" >> ${logPath}; echo "✅ Image Tagged" >> ${logPath};
docker push ${registryTag} 2>> ${logPath} || { docker push ${registryTag} 2>> ${logPath} || {
echo "❌ Error pushing image" >> ${logPath}; echo "❌ Error pushing image" >> ${logPath};
exit 1; exit 1;
@@ -92,7 +98,6 @@ export const uploadImageRemoteCommand = (
`; `;
return command; return command;
} catch (error) { } catch (error) {
console.log(error);
throw error; throw error;
} }
}; };

View File

@@ -238,9 +238,11 @@ export const startServiceRemote = async (serverId: string, appName: string) => {
export const removeService = async ( export const removeService = async (
appName: string, appName: string,
serverId?: string | null, serverId?: string | null,
deleteVolumes = false,
) => { ) => {
try { try {
const command = `docker service rm ${appName}`; const command = `docker service rm ${appName}`;
if (serverId) { if (serverId) {
await execAsyncRemote(serverId, command); await execAsyncRemote(serverId, command);
} else { } else {

View File

@@ -59,46 +59,49 @@ export const sendBuildErrorNotifications = async ({
} }
if (discord) { if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, { await sendDiscordNotification(discord, {
title: "> `⚠️` Build Failed", title: decorate(">", "`⚠️` Build Failed"),
color: 0xed4245, color: 0xed4245,
fields: [ fields: [
{ {
name: "`🛠️` Project", name: decorate("`🛠️`", "Project"),
value: projectName, value: projectName,
inline: true, inline: true,
}, },
{ {
name: "`⚙️` Application", name: decorate("`⚙️`", "Application"),
value: applicationName, value: applicationName,
inline: true, inline: true,
}, },
{ {
name: "`❔` Type", name: decorate("`❔`", "Type"),
value: applicationType, value: applicationType,
inline: true, inline: true,
}, },
{ {
name: "`📅` Date", name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`, value: `<t:${unixDate}:D>`,
inline: true, inline: true,
}, },
{ {
name: "`⌚` Time", name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`, value: `<t:${unixDate}:t>`,
inline: true, inline: true,
}, },
{ {
name: "`❓`Type", name: decorate("`❓`", "Type"),
value: "Failed", value: "Failed",
inline: true, inline: true,
}, },
{ {
name: "`⚠️` Error Message", name: decorate("`⚠️`", "Error Message"),
value: `\`\`\`${errorMessage}\`\`\``, value: `\`\`\`${errorMessage}\`\`\``,
}, },
{ {
name: "`🧷` Build Link", name: decorate("`🧷`", "Build Link"),
value: `[Click here to access build link](${buildLink})`, value: `[Click here to access build link](${buildLink})`,
}, },
], ],

View File

@@ -57,42 +57,45 @@ export const sendBuildSuccessNotifications = async ({
} }
if (discord) { if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, { await sendDiscordNotification(discord, {
title: "> `✅` Build Success", title: "> `✅` Build Success",
color: 0x57f287, color: 0x57f287,
fields: [ fields: [
{ {
name: "`🛠️` Project", name: decorate("`🛠️`", "Project"),
value: projectName, value: projectName,
inline: true, inline: true,
}, },
{ {
name: "`⚙️` Application", name: decorate("`⚙️`", "Application"),
value: applicationName, value: applicationName,
inline: true, inline: true,
}, },
{ {
name: "`❔` Application Type", name: decorate("`❔`", "Type"),
value: applicationType, value: applicationType,
inline: true, inline: true,
}, },
{ {
name: "`📅` Date", name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`, value: `<t:${unixDate}:D>`,
inline: true, inline: true,
}, },
{ {
name: "`⌚` Time", name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`, value: `<t:${unixDate}:t>`,
inline: true, inline: true,
}, },
{ {
name: "`❓` Type", name: decorate("`❓`", "Type"),
value: "Successful", value: "Successful",
inline: true, inline: true,
}, },
{ {
name: "`🧷` Build Link", name: decorate("`🧷`", "Build Link"),
value: `[Click here to access build link](${buildLink})`, value: `[Click here to access build link](${buildLink})`,
}, },
], ],

View File

@@ -62,40 +62,43 @@ export const sendDatabaseBackupNotifications = async ({
} }
if (discord) { if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, { await sendDiscordNotification(discord, {
title: title:
type === "success" type === "success"
? "> `✅` Database Backup Successful" ? decorate(">", "`✅` Database Backup Successful")
: "> `❌` Database Backup Failed", : decorate(">", "`❌` Database Backup Failed"),
color: type === "success" ? 0x57f287 : 0xed4245, color: type === "success" ? 0x57f287 : 0xed4245,
fields: [ fields: [
{ {
name: "`🛠️` Project", name: decorate("`🛠️`", "Project"),
value: projectName, value: projectName,
inline: true, inline: true,
}, },
{ {
name: "`⚙️` Application", name: decorate("`⚙️`", "Application"),
value: applicationName, value: applicationName,
inline: true, inline: true,
}, },
{ {
name: "`❔` Database", name: decorate("`❔`", "Database"),
value: databaseType, value: databaseType,
inline: true, inline: true,
}, },
{ {
name: "`📅` Date", name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`, value: `<t:${unixDate}:D>`,
inline: true, inline: true,
}, },
{ {
name: "`⌚` Time", name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`, value: `<t:${unixDate}:t>`,
inline: true, inline: true,
}, },
{ {
name: "`❓` Type", name: decorate("`❓`", "Type"),
value: type value: type
.replace("error", "Failed") .replace("error", "Failed")
.replace("success", "Successful"), .replace("success", "Successful"),
@@ -104,7 +107,7 @@ export const sendDatabaseBackupNotifications = async ({
...(type === "error" && errorMessage ...(type === "error" && errorMessage
? [ ? [
{ {
name: "`⚠️` Error Message", name: decorate("`⚠️`", "Error Message"),
value: `\`\`\`${errorMessage}\`\`\``, value: `\`\`\`${errorMessage}\`\`\``,
}, },
] ]

Some files were not shown because too many files have changed in this diff Show More