Merge pull request #1006 from Dokploy/canary

v0.16.0
This commit is contained in:
Mauricio Siu
2024-12-26 22:52:02 -06:00
committed by GitHub
116 changed files with 12143 additions and 1420 deletions

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,17 +20,17 @@ 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;
} }
}; };
const handleScroll = () => { const handleScroll = () => {
if (!scrollRef.current) return; if (!scrollRef.current) return;
@@ -37,7 +38,7 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10; const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
setAutoScroll(isAtBottom); setAutoScroll(isAtBottom);
}; };
useEffect(() => { useEffect(() => {
if (!open || !logPath) return; if (!open || !logPath) return;
@@ -69,20 +70,34 @@ 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();
if (autoScroll && scrollRef.current) { if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
} }
}, [filteredLogs, autoScroll]); }, [filteredLogs, autoScroll]);
return ( return (
<Dialog <Dialog
@@ -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,19 +150,17 @@ 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">
( <Loader2 className="h-6 w-6 animate-spin" />
<div className="flex justify-center items-center h-full text-muted-foreground"> </div>
<Loader2 className="h-6 w-6 animate-spin" /> )}
</div>
)}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

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

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { import {
Card, Card,
CardContent, CardContent,
@@ -15,6 +16,7 @@ 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 { api } from "@/utils/api";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
@@ -29,28 +31,67 @@ export const DockerLogs = dynamic(
}, },
); );
export const badgeStateColor = (state: string) => {
switch (state) {
case "running":
return "green";
case "exited":
case "shutdown":
return "red";
case "accepted":
case "created":
return "blue";
default:
return "default";
}
};
interface Props { interface Props {
appName: string; appName: string;
serverId?: string; serverId?: string;
} }
export const ShowDockerLogs = ({ appName, serverId }: Props) => { export const ShowDockerLogs = ({ appName, serverId }: Props) => {
const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
serverId,
},
{
enabled: !!appName,
},
);
const [containerId, setContainerId] = useState<string | undefined>(); const [containerId, setContainerId] = useState<string | undefined>();
const [option, setOption] = useState<"swarm" | "native">("native");
const { data: services, isLoading: servicesLoading } =
api.docker.getServiceContainersByAppName.useQuery(
{
appName,
serverId,
},
{
enabled: !!appName && option === "swarm",
},
);
const { data: containers, isLoading: containersLoading } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
serverId,
},
{
enabled: !!appName && option === "native",
},
);
useEffect(() => { useEffect(() => {
if (data && data?.length > 0) { if (option === "native") {
setContainerId(data[0]?.containerId); if (containers && containers?.length > 0) {
setContainerId(containers[0]?.containerId);
}
} else {
if (services && services?.length > 0) {
setContainerId(services[0]?.containerId);
}
} }
}, [data]); }, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLenght =
option === "native" ? containers?.length : services?.length;
return ( return (
<Card className="bg-background"> <Card className="bg-background">
@@ -62,7 +103,21 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
<Label>Select a container to view logs</Label> <div className="flex flex-row justify-between items-center gap-2">
<Label>Select a container to view logs</Label>
<div className="flex flex-row gap-2 items-center">
<span className="text-sm text-muted-foreground">
{option === "native" ? "Native" : "Swarm"}
</span>
<Switch
checked={option === "native"}
onCheckedChange={(checked) => {
setOption(checked ? "native" : "swarm");
}}
/>
</div>
</div>
<Select onValueChange={setContainerId} value={containerId}> <Select onValueChange={setContainerId} value={containerId}>
<SelectTrigger> <SelectTrigger>
{isLoading ? ( {isLoading ? (
@@ -76,21 +131,45 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
{data?.map((container) => ( {option === "native" ? (
<SelectItem <div>
key={container.containerId} {containers?.map((container) => (
value={container.containerId} <SelectItem
> key={container.containerId}
{container.name} ({container.containerId}) {container.state} value={container.containerId}
</SelectItem> >
))} {container.name} ({container.containerId}){" "}
<SelectLabel>Containers ({data?.length})</SelectLabel> <Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
</SelectItem>
))}
</div>
) : (
<>
{services?.map((container) => (
<SelectItem
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}@{container.node}
)
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
</SelectItem>
))}
</>
)}
<SelectLabel>Containers ({containersLenght})</SelectLabel>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
<DockerLogs <DockerLogs
serverId={serverId || ""} serverId={serverId || ""}
containerId={containerId || "select-a-container"} containerId={containerId || "select-a-container"}
runType={option}
/> />
</CardContent> </CardContent>
</Card> </Card>

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,120 +90,131 @@ 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"
<div> ? "bg-green-500"
<span className="text-sm text-muted-foreground"> : status === "running"
No deployments found ? "bg-yellow-500"
</span> : "bg-red-500"
</div> }`}
) : ( />
<div className="flex items-center gap-2">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{previewDeployment?.pullRequestTitle}
</span>
<StatusTooltip
status={previewDeployment.previewStatus}
className="size-2.5"
/>
</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="p-4">
<div className="flex items-center gap-2"> <div className="flex items-start justify-between mb-3">
<GithubIcon /> <div className="flex items-start gap-3">
<Link <GitPullRequest className="size-5 text-muted-foreground mt-1 flex-shrink-0" />
target="_blank" <div>
href={previewDeployment?.pullRequestURL} <div className="font-medium text-sm">
className="break-all text-sm text-muted-foreground w-fit hover:underline hover:text-foreground" {deployment.pullRequestTitle}
> </div>
Pull Request URL <div className="text-sm text-muted-foreground mt-1">
</Link> {deployment.branch}
</div> </div>
)}
</div>
<div className="flex flex-col ">
<span>Domain </span>
<div className="flex flex-row items-center gap-4">
<Link
target="_blank"
href={`http://${domain?.host}`}
className="text-sm text-muted-foreground w-fit hover:underline hover:text-foreground"
>
{domain?.host}
</Link>
<AddPreviewDomain
previewDeploymentId={
previewDeployment.previewDeploymentId
}
domainId={domain?.domainId}
>
<Button variant="outline" size="sm">
<Pencil className="size-4 text-muted-foreground" />
</Button>
</AddPreviewDomain>
</div> </div>
</div> </div>
<Badge variant="outline" className="gap-2">
<StatusTooltip
status={deployment.previewStatus}
className="size-2"
/>
<DateTooltip date={deployment.createdAt} />
</Badge>
</div> </div>
<div className="flex flex-col sm:items-end gap-2 max-sm:w-full"> <div className="pl-8 space-y-3">
{previewDeployment?.createdAt && ( <div className="relative flex-grow">
<div className="text-sm capitalize text-muted-foreground"> <Input
<DateTooltip value={deploymentUrl}
date={previewDeployment?.createdAt} readOnly
/> className="pr-8 text-sm text-blue-500 hover:text-blue-600 cursor-pointer"
</div> onClick={() =>
)} window.open(deploymentUrl, "_blank")
<ShowPreviewBuilds }
deployments={previewDeployment?.deployments || []} />
serverId={data?.serverId || ""} <ExternalLink className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-gray-400" />
/> </div>
<ShowModalLogs <div className="flex gap-2 opacity-80 group-hover:opacity-100 transition-opacity">
appName={previewDeployment.appName} <Button
serverId={data?.serverId || ""} variant="outline"
> size="sm"
<Button variant="outline">View Logs</Button> className="gap-2"
</ShowModalLogs> onClick={() =>
window.open(deployment.pullRequestURL, "_blank")
<DialogAction }
title="Delete Preview" >
description="Are you sure you want to delete this preview?" <GithubIcon className="size-4" />
onClick={() => { Pull Request
deletePreviewDeployment({
previewDeploymentId:
previewDeployment.previewDeploymentId,
})
.then(() => {
refetchPreviewDeployments();
toast.success("Preview deployment deleted");
})
.catch((error) => {
toast.error(error.message);
});
}}
>
<Button variant="destructive" isLoading={isLoading}>
Delete Preview
</Button> </Button>
</DialogAction> <ShowModalLogs
appName={deployment.appName}
serverId={data?.serverId || ""}
>
<Button
variant="outline"
size="sm"
className="gap-2"
>
<FileText className="size-4" />
Logs
</Button>
</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
title="Delete Preview"
description="Are you sure you want to delete this preview?"
onClick={() =>
handleDeletePreviewDeployment(
deployment.previewDeploymentId,
)
}
>
<Button
variant="ghost"
size="sm"
isLoading={isLoading}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="size-4" />
</Button>
</DialogAction>
</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,15 +26,16 @@ 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);
const scrollToBottom = () => { const scrollToBottom = () => {
if (autoScroll && scrollRef.current) { if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
} }
}; };
const handleScroll = () => { const handleScroll = () => {
if (!scrollRef.current) return; if (!scrollRef.current) return;
@@ -42,8 +43,7 @@ export const ShowDeploymentCompose = ({
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10; const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
setAutoScroll(isAtBottom); setAutoScroll(isAtBottom);
}; };
useEffect(() => { useEffect(() => {
if (!open || !logPath) return; if (!open || !logPath) return;
@@ -76,19 +76,34 @@ 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();
if (autoScroll && scrollRef.current) { if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
} }
}, [filteredLogs, autoScroll]); }, [filteredLogs, autoScroll]);
return ( return (
<Dialog <Dialog
@@ -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

@@ -0,0 +1,165 @@
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
export const DockerLogs = dynamic(
() =>
import("@/components/dashboard/docker/logs/docker-logs-id").then(
(e) => e.DockerLogsId,
),
{
ssr: false,
},
);
interface Props {
appName: string;
serverId?: string;
}
badgeStateColor;
export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
const [option, setOption] = useState<"swarm" | "native">("native");
const [containerId, setContainerId] = useState<string | undefined>();
const { data: services, isLoading: servicesLoading } =
api.docker.getStackContainersByAppName.useQuery(
{
appName,
serverId,
},
{
enabled: !!appName && option === "swarm",
},
);
const { data: containers, isLoading: containersLoading } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
appType: "stack",
serverId,
},
{
enabled: !!appName && option === "native",
},
);
useEffect(() => {
if (option === "native") {
if (containers && containers?.length > 0) {
setContainerId(containers[0]?.containerId);
}
} else {
if (services && services?.length > 0) {
setContainerId(services[0]?.containerId);
}
}
}, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLenght =
option === "native" ? containers?.length : services?.length;
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Logs</CardTitle>
<CardDescription>
Watch the logs of the application in real time
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-row justify-between items-center gap-2">
<Label>Select a container to view logs</Label>
<div className="flex flex-row gap-2 items-center">
<span className="text-sm text-muted-foreground">
{option === "native" ? "Native" : "Swarm"}
</span>
<Switch
checked={option === "native"}
onCheckedChange={(checked) => {
setOption(checked ? "native" : "swarm");
}}
/>
</div>
</div>
<Select onValueChange={setContainerId} value={containerId}>
<SelectTrigger>
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<SelectValue placeholder="Select a container" />
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{option === "native" ? (
<div>
{containers?.map((container) => (
<SelectItem
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}){" "}
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
</SelectItem>
))}
</div>
) : (
<>
{services?.map((container) => (
<SelectItem
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}@{container.node}
)
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
</SelectItem>
))}
</>
)}
<SelectLabel>Containers ({containersLenght})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<DockerLogs
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType={option}
/>
</CardContent>
</Card>
);
};

View File

@@ -1,3 +1,5 @@
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { Badge } from "@/components/ui/badge";
import { import {
Card, Card,
CardContent, CardContent,
@@ -87,7 +89,10 @@ export const ShowDockerLogsCompose = ({
key={container.containerId} key={container.containerId}
value={container.containerId} value={container.containerId}
> >
{container.name} ({container.containerId}) {container.state} {container.name} ({container.containerId}){" "}
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
</SelectItem> </SelectItem>
))} ))}
<SelectLabel>Containers ({data?.length})</SelectLabel> <SelectLabel>Containers ({data?.length})</SelectLabel>
@@ -97,6 +102,7 @@ export const ShowDockerLogsCompose = ({
<DockerLogs <DockerLogs
serverId={serverId || ""} serverId={serverId || ""}
containerId={containerId || "select-a-container"} containerId={containerId || "select-a-container"}
runType="native"
/> />
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -12,6 +12,7 @@ import { type LogLine, getLogType, parseLogs } from "./utils";
interface Props { interface Props {
containerId: string; containerId: string;
serverId?: string | null; serverId?: string | null;
runType: "swarm" | "native";
} }
export const priorities = [ export const priorities = [
@@ -37,7 +38,11 @@ export const priorities = [
}, },
]; ];
export const DockerLogsId: React.FC<Props> = ({ containerId, serverId }) => { export const DockerLogsId: React.FC<Props> = ({
containerId,
serverId,
runType,
}) => {
const { data } = api.docker.getConfig.useQuery( const { data } = api.docker.getConfig.useQuery(
{ {
containerId, containerId,
@@ -104,6 +109,7 @@ export const DockerLogsId: React.FC<Props> = ({ containerId, serverId }) => {
tail: lines.toString(), tail: lines.toString(),
since, since,
search, search,
runType,
}); });
if (serverId) { if (serverId) {

View File

@@ -46,7 +46,11 @@ export const ShowDockerModalLogs = ({
<DialogDescription>View the logs for {containerId}</DialogDescription> <DialogDescription>View the logs for {containerId}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-4 pt-2.5"> <div className="flex flex-col gap-4 pt-2.5">
<DockerLogsId containerId={containerId || ""} serverId={serverId} /> <DockerLogsId
containerId={containerId || ""}
serverId={serverId}
runType="native"
/>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -0,0 +1,58 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import dynamic from "next/dynamic";
import type React from "react";
export const DockerLogsId = dynamic(
() =>
import("@/components/dashboard/docker/logs/docker-logs-id").then(
(e) => e.DockerLogsId,
),
{
ssr: false,
},
);
interface Props {
containerId: string;
children?: React.ReactNode;
serverId?: string | null;
}
export const ShowDockerModalStackLogs = ({
containerId,
children,
serverId,
}: Props) => {
return (
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
{children}
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-7xl">
<DialogHeader>
<DialogTitle>View Logs</DialogTitle>
<DialogDescription>View the logs for {containerId}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2.5">
<DockerLogsId
containerId={containerId || ""}
serverId={serverId}
runType="swarm"
/>
</div>
</DialogContent>
</Dialog>
);
};

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;
return (
<span key={index} className={segment.className || undefined}> const segments = content.split(searchRegex);
{parts.map((part, partIndex) => const wrappedSegments = segments
part.toLowerCase() === term.toLowerCase() ? ( .map((segment: string) =>
<span segment.toLowerCase() === term.toLowerCase()
key={partIndex} ? `<span${attrs} class="bg-yellow-200/50 dark:bg-yellow-900/50">${segment}</span>`
className="bg-yellow-200 dark:bg-yellow-900" : segment,
> )
{part} .join("");
</span>
) : ( return `<span${attrs}>${wrappedSegments}</span>`;
part },
), );
)}
</span> return (
); <span
}); className="transition-colors"
dangerouslySetInnerHTML={{ __html: modifiedContent }}
/>
);
}; };
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

@@ -213,7 +213,7 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
name="appName" name="appName"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>AppName</FormLabel> <FormLabel>App Name</FormLabel>
<FormControl> <FormControl>
<Input placeholder="my-app" {...field} /> <Input placeholder="my-app" {...field} />
</FormControl> </FormControl>

View File

@@ -220,7 +220,7 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
name="appName" name="appName"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>AppName</FormLabel> <FormLabel>App Name</FormLabel>
<FormControl> <FormControl>
<Input placeholder="my-app" {...field} /> <Input placeholder="my-app" {...field} />
</FormControl> </FormControl>

View File

@@ -18,6 +18,7 @@ import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -35,6 +36,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { slugify } from "@/lib/slug"; import { slugify } from "@/lib/slug";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
@@ -95,6 +97,7 @@ const mySchema = z.discriminatedUnion("type", [
.object({ .object({
type: z.literal("mongo"), type: z.literal("mongo"),
databaseUser: z.string().default("mongo"), databaseUser: z.string().default("mongo"),
replicaSets: z.boolean().default(false),
}) })
.merge(baseDatabaseSchema), .merge(baseDatabaseSchema),
z z
@@ -216,6 +219,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
databaseUser: databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type], data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId, serverId: data.serverId,
replicaSets: data.replicaSets,
}); });
} else if (data.type === "redis") { } else if (data.type === "redis") {
promise = redisMutation.mutateAsync({ promise = redisMutation.mutateAsync({
@@ -412,7 +416,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
name="appName" name="appName"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>AppName</FormLabel> <FormLabel>App Name</FormLabel>
<FormControl> <FormControl>
<Input placeholder="my-app" {...field} /> <Input placeholder="my-app" {...field} />
</FormControl> </FormControl>
@@ -471,6 +475,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 +496,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
<Input <Input
type="password" type="password"
placeholder="******************" placeholder="******************"
autoComplete="off"
{...field} {...field}
/> />
</FormControl> </FormControl>
@@ -540,6 +546,30 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
); );
}} }}
/> />
{type === "mongo" && (
<FormField
control={form.control}
name="replicaSets"
render={({ field }) => {
return (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Use Replica Sets</FormLabel>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
)}
</div> </div>
</div> </div>
</form> </form>

View File

@@ -1,35 +1,35 @@
import { DateTooltip } from "@/components/shared/date-tooltip"; import { DateTooltip } from "@/components/shared/date-tooltip";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
AlertDialogContent, AlertDialogContent,
AlertDialogDescription, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { import {
AlertTriangle, AlertTriangle,
BookIcon, BookIcon,
ExternalLink, ExternalLink,
ExternalLinkIcon, ExternalLinkIcon,
FolderInput, FolderInput,
MoreHorizontalIcon, MoreHorizontalIcon,
TrashIcon, TrashIcon,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Fragment } from "react"; import { Fragment } from "react";
@@ -38,257 +38,257 @@ import { ProjectEnviroment } from "./project-enviroment";
import { UpdateProject } from "./update"; import { UpdateProject } from "./update";
export const ShowProjects = () => { export const ShowProjects = () => {
const utils = api.useUtils(); const utils = api.useUtils();
const { data } = api.project.all.useQuery(); const { data } = api.project.all.useQuery();
const { data: auth } = api.auth.get.useQuery(); const { data: auth } = api.auth.get.useQuery();
const { data: user } = api.user.byAuthId.useQuery( const { data: user } = api.user.byAuthId.useQuery(
{ {
authId: auth?.id || "", authId: auth?.id || "",
}, },
{ {
enabled: !!auth?.id && auth?.rol === "user", enabled: !!auth?.id && auth?.rol === "user",
} },
); );
const { mutateAsync } = api.project.remove.useMutation(); const { mutateAsync } = api.project.remove.useMutation();
return ( return (
<> <>
{data?.length === 0 && ( {data?.length === 0 && (
<div className="mt-6 flex h-[50vh] w-full flex-col items-center justify-center space-y-4"> <div className="mt-6 flex h-[50vh] w-full flex-col items-center justify-center space-y-4">
<FolderInput className="size-10 md:size-28 text-muted-foreground" /> <FolderInput className="size-10 md:size-28 text-muted-foreground" />
<span className="text-center font-medium text-muted-foreground"> <span className="text-center font-medium text-muted-foreground">
No projects added yet. Click on Create project. No projects added yet. Click on Create project.
</span> </span>
</div> </div>
)} )}
<div className="mt-6 w-full grid sm:grid-cols-2 lg:grid-cols-3 flex-wrap gap-5 pb-10"> <div className="mt-6 w-full grid sm:grid-cols-2 lg:grid-cols-3 flex-wrap gap-5 pb-10">
{data?.map((project) => { {data?.map((project) => {
const emptyServices = const emptyServices =
project?.mariadb.length === 0 && project?.mariadb.length === 0 &&
project?.mongo.length === 0 && project?.mongo.length === 0 &&
project?.mysql.length === 0 && project?.mysql.length === 0 &&
project?.postgres.length === 0 && project?.postgres.length === 0 &&
project?.redis.length === 0 && project?.redis.length === 0 &&
project?.applications.length === 0 && project?.applications.length === 0 &&
project?.compose.length === 0; project?.compose.length === 0;
const totalServices = const totalServices =
project?.mariadb.length + project?.mariadb.length +
project?.mongo.length + project?.mongo.length +
project?.mysql.length + project?.mysql.length +
project?.postgres.length + project?.postgres.length +
project?.redis.length + project?.redis.length +
project?.applications.length + project?.applications.length +
project?.compose.length; project?.compose.length;
const flattedDomains = [ const flattedDomains = [
...project.applications.flatMap((a) => a.domains), ...project.applications.flatMap((a) => a.domains),
...project.compose.flatMap((a) => a.domains), ...project.compose.flatMap((a) => a.domains),
]; ];
const renderDomainsDropdown = ( const renderDomainsDropdown = (
item: typeof project.compose | typeof project.applications item: typeof project.compose | typeof project.applications,
) => ) =>
item[0] ? ( item[0] ? (
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuLabel> <DropdownMenuLabel>
{"applicationId" in item[0] ? "Applications" : "Compose"} {"applicationId" in item[0] ? "Applications" : "Compose"}
</DropdownMenuLabel> </DropdownMenuLabel>
{item.map((a) => ( {item.map((a) => (
<Fragment <Fragment
key={"applicationId" in a ? a.applicationId : a.composeId} key={"applicationId" in a ? a.applicationId : a.composeId}
> >
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs "> <DropdownMenuLabel className="font-normal capitalize text-xs ">
{a.name} {a.name}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{a.domains.map((domain) => ( {a.domains.map((domain) => (
<DropdownMenuItem key={domain.domainId} asChild> <DropdownMenuItem key={domain.domainId} asChild>
<Link <Link
className="space-x-4 text-xs cursor-pointer justify-between" className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank" target="_blank"
href={`${domain.https ? "https" : "http"}://${ href={`${domain.https ? "https" : "http"}://${
domain.host domain.host
}${domain.path}`} }${domain.path}`}
> >
<span>{domain.host}</span> <span>{domain.host}</span>
<ExternalLink className="size-4 shrink-0" /> <ExternalLink className="size-4 shrink-0" />
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuGroup> </DropdownMenuGroup>
</Fragment> </Fragment>
))} ))}
</DropdownMenuGroup> </DropdownMenuGroup>
) : null; ) : null;
return ( return (
<div key={project.projectId} className="w-full lg:max-w-md"> <div key={project.projectId} className="w-full lg:max-w-md">
<Link href={`/dashboard/project/${project.projectId}`}> <Link href={`/dashboard/project/${project.projectId}`}>
<Card className="group relative w-full bg-transparent transition-colors hover:bg-card"> <Card className="group relative w-full bg-transparent transition-colors hover:bg-card">
{flattedDomains.length > 1 ? ( {flattedDomains.length > 1 ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100" className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
size="sm" size="sm"
variant="default" variant="default"
> >
<ExternalLinkIcon className="size-3.5" /> <ExternalLinkIcon className="size-3.5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className="w-[200px] space-y-2" className="w-[200px] space-y-2"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{renderDomainsDropdown(project.applications)} {renderDomainsDropdown(project.applications)}
{renderDomainsDropdown(project.compose)} {renderDomainsDropdown(project.compose)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : flattedDomains[0] ? ( ) : flattedDomains[0] ? (
<Button <Button
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100" className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
size="sm" size="sm"
variant="default" variant="default"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<Link <Link
href={`${ href={`${
flattedDomains[0].https ? "https" : "http" flattedDomains[0].https ? "https" : "http"
}://${flattedDomains[0].host}${flattedDomains[0].path}`} }://${flattedDomains[0].host}${flattedDomains[0].path}`}
target="_blank" target="_blank"
> >
<ExternalLinkIcon className="size-3.5" /> <ExternalLinkIcon className="size-3.5" />
</Link> </Link>
</Button> </Button>
) : null} ) : null}
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between gap-2"> <CardTitle className="flex items-center justify-between gap-2">
<span className="flex flex-col gap-1.5"> <span className="flex flex-col gap-1.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BookIcon className="size-4 text-muted-foreground" /> <BookIcon className="size-4 text-muted-foreground" />
<span className="text-base font-medium leading-none"> <span className="text-base font-medium leading-none">
{project.name} {project.name}
</span> </span>
</div> </div>
<span className="text-sm font-medium text-muted-foreground"> <span className="text-sm font-medium text-muted-foreground">
{project.description} {project.description}
</span> </span>
</span> </span>
<div className="flex self-start space-x-1"> <div className="flex self-start space-x-1">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="px-2" className="px-2"
> >
<MoreHorizontalIcon className="size-5" /> <MoreHorizontalIcon className="size-5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-[200px] space-y-2"> <DropdownMenuContent className="w-[200px] space-y-2">
<DropdownMenuLabel className="font-normal"> <DropdownMenuLabel className="font-normal">
Actions Actions
</DropdownMenuLabel> </DropdownMenuLabel>
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<ProjectEnviroment <ProjectEnviroment
projectId={project.projectId} projectId={project.projectId}
/> />
</div> </div>
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<UpdateProject projectId={project.projectId} /> <UpdateProject projectId={project.projectId} />
</div> </div>
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
{(auth?.rol === "admin" || {(auth?.rol === "admin" ||
user?.canDeleteProjects) && ( user?.canDeleteProjects) && (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger className="w-full"> <AlertDialogTrigger className="w-full">
<DropdownMenuItem <DropdownMenuItem
className="w-full cursor-pointer space-x-3" className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()} onSelect={(e) => e.preventDefault()}
> >
<TrashIcon className="size-4" /> <TrashIcon className="size-4" />
<span>Delete</span> <span>Delete</span>
</DropdownMenuItem> </DropdownMenuItem>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle> <AlertDialogTitle>
Are you sure to delete this project? Are you sure to delete this project?
</AlertDialogTitle> </AlertDialogTitle>
{!emptyServices ? ( {!emptyServices ? (
<div className="flex flex-row gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950"> <div className="flex flex-row gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
<AlertTriangle className="text-yellow-600 dark:text-yellow-400" /> <AlertTriangle className="text-yellow-600 dark:text-yellow-400" />
<span className="text-sm text-yellow-600 dark:text-yellow-400"> <span className="text-sm text-yellow-600 dark:text-yellow-400">
You have active services, please You have active services, please
delete them first delete them first
</span> </span>
</div> </div>
) : ( ) : (
<AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone This action cannot be undone
</AlertDialogDescription> </AlertDialogDescription>
)} )}
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel> <AlertDialogCancel>
Cancel Cancel
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
disabled={!emptyServices} disabled={!emptyServices}
onClick={async () => { onClick={async () => {
await mutateAsync({ await mutateAsync({
projectId: project.projectId, projectId: project.projectId,
}) })
.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(() => {
utils.project.all.invalidate(); utils.project.all.invalidate();
}); });
}} }}
> >
Delete Delete
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
)} )}
</div> </div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardFooter className="pt-4"> <CardFooter className="pt-4">
<div className="space-y-1 text-sm flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4"> <div className="space-y-1 text-sm flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
<DateTooltip date={project.createdAt}> <DateTooltip date={project.createdAt}>
Created Created
</DateTooltip> </DateTooltip>
<span> <span>
{totalServices}{" "} {totalServices}{" "}
{totalServices === 1 ? "service" : "services"} {totalServices === 1 ? "service" : "services"}
</span> </span>
</div> </div>
</CardFooter> </CardFooter>
</Card> </Card>
</Link> </Link>
</div> </div>
); );
})} })}
</div> </div>
</> </>
); );
}; };

View File

@@ -1,189 +1,189 @@
"use client"; "use client";
import React from "react";
import { import {
Command, MariadbIcon,
CommandEmpty, MongodbIcon,
CommandList, MysqlIcon,
CommandGroup, PostgresqlIcon,
CommandInput, RedisIcon,
CommandItem, } from "@/components/icons/data-tools-icons";
CommandDialog, import { Badge } from "@/components/ui/badge";
CommandSeparator, import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command"; } from "@/components/ui/command";
import { useRouter } from "next/router";
import { import {
extractServices, type Services,
type Services, extractServices,
} from "@/pages/dashboard/project/[projectId]"; } from "@/pages/dashboard/project/[projectId]";
import { api } from "@/utils/api";
import type { findProjectById } from "@dokploy/server/services/project"; import type { findProjectById } from "@dokploy/server/services/project";
import { BookIcon, CircuitBoard, GlobeIcon } from "lucide-react"; import { BookIcon, CircuitBoard, GlobeIcon } from "lucide-react";
import { import { useRouter } from "next/router";
MariadbIcon, import React from "react";
MongodbIcon,
MysqlIcon,
PostgresqlIcon,
RedisIcon,
} from "@/components/icons/data-tools-icons";
import { api } from "@/utils/api";
import { Badge } from "@/components/ui/badge";
import { StatusTooltip } from "../shared/status-tooltip"; import { StatusTooltip } from "../shared/status-tooltip";
type Project = Awaited<ReturnType<typeof findProjectById>>; type Project = Awaited<ReturnType<typeof findProjectById>>;
export const SearchCommand = () => { export const SearchCommand = () => {
const router = useRouter(); const router = useRouter();
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState(""); const [search, setSearch] = React.useState("");
const { data } = api.project.all.useQuery(); const { data } = api.project.all.useQuery();
const { data: isCloud, isLoading } = api.settings.isCloud.useQuery(); const { data: isCloud, isLoading } = api.settings.isCloud.useQuery();
React.useEffect(() => { React.useEffect(() => {
const down = (e: KeyboardEvent) => { const down = (e: KeyboardEvent) => {
if (e.key === "j" && (e.metaKey || e.ctrlKey)) { if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
e.preventDefault(); e.preventDefault();
setOpen((open) => !open); setOpen((open) => !open);
} }
}; };
document.addEventListener("keydown", down); document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down); return () => document.removeEventListener("keydown", down);
}, []); }, []);
return ( return (
<div> <div>
<CommandDialog open={open} onOpenChange={setOpen}> <CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput <CommandInput
placeholder={"Search projects or settings"} placeholder={"Search projects or settings"}
value={search} value={search}
onValueChange={setSearch} onValueChange={setSearch}
/> />
<CommandList> <CommandList>
<CommandEmpty> <CommandEmpty>
No projects added yet. Click on Create project. No projects added yet. Click on Create project.
</CommandEmpty> </CommandEmpty>
<CommandGroup heading={"Projects"}> <CommandGroup heading={"Projects"}>
<CommandList> <CommandList>
{data?.map((project) => ( {data?.map((project) => (
<CommandItem <CommandItem
key={project.projectId} key={project.projectId}
onSelect={() => { onSelect={() => {
router.push(`/dashboard/project/${project.projectId}`); router.push(`/dashboard/project/${project.projectId}`);
setOpen(false); setOpen(false);
}} }}
> >
<BookIcon className="size-4 text-muted-foreground mr-2" /> <BookIcon className="size-4 text-muted-foreground mr-2" />
{project.name} {project.name}
</CommandItem> </CommandItem>
))} ))}
</CommandList> </CommandList>
</CommandGroup> </CommandGroup>
<CommandSeparator /> <CommandSeparator />
<CommandGroup heading={"Services"}> <CommandGroup heading={"Services"}>
<CommandList> <CommandList>
{data?.map((project) => { {data?.map((project) => {
const applications: Services[] = extractServices(project); const applications: Services[] = extractServices(project);
return applications.map((application) => ( return applications.map((application) => (
<CommandItem <CommandItem
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);
}} }}
> >
{application.type === "postgres" && ( {application.type === "postgres" && (
<PostgresqlIcon className="h-6 w-6 mr-2" /> <PostgresqlIcon className="h-6 w-6 mr-2" />
)} )}
{application.type === "redis" && ( {application.type === "redis" && (
<RedisIcon className="h-6 w-6 mr-2" /> <RedisIcon className="h-6 w-6 mr-2" />
)} )}
{application.type === "mariadb" && ( {application.type === "mariadb" && (
<MariadbIcon className="h-6 w-6 mr-2" /> <MariadbIcon className="h-6 w-6 mr-2" />
)} )}
{application.type === "mongo" && ( {application.type === "mongo" && (
<MongodbIcon className="h-6 w-6 mr-2" /> <MongodbIcon className="h-6 w-6 mr-2" />
)} )}
{application.type === "mysql" && ( {application.type === "mysql" && (
<MysqlIcon className="h-6 w-6 mr-2" /> <MysqlIcon className="h-6 w-6 mr-2" />
)} )}
{application.type === "application" && ( {application.type === "application" && (
<GlobeIcon className="h-6 w-6 mr-2" /> <GlobeIcon className="h-6 w-6 mr-2" />
)} )}
{application.type === "compose" && ( {application.type === "compose" && (
<CircuitBoard className="h-6 w-6 mr-2" /> <CircuitBoard className="h-6 w-6 mr-2" />
)} )}
<span className="flex-grow"> <span className="flex-grow">
{project.name} / {application.name}{" "} {project.name} / {application.name}{" "}
<div style={{ display: "none" }}>{application.id}</div> <div style={{ display: "none" }}>{application.id}</div>
</span> </span>
<div> <div>
<StatusTooltip status={application.status} /> <StatusTooltip status={application.status} />
</div> </div>
</CommandItem> </CommandItem>
)); ));
})} })}
</CommandList> </CommandList>
</CommandGroup> </CommandGroup>
<CommandSeparator /> <CommandSeparator />
<CommandGroup heading={"Application"} hidden={true}> <CommandGroup heading={"Application"} hidden={true}>
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
router.push("/dashboard/projects"); router.push("/dashboard/projects");
setOpen(false); setOpen(false);
}} }}
> >
Projects Projects
</CommandItem> </CommandItem>
{!isCloud && ( {!isCloud && (
<> <>
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
router.push("/dashboard/monitoring"); router.push("/dashboard/monitoring");
setOpen(false); setOpen(false);
}} }}
> >
Monitoring Monitoring
</CommandItem> </CommandItem>
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
router.push("/dashboard/traefik"); router.push("/dashboard/traefik");
setOpen(false); setOpen(false);
}} }}
> >
Traefik Traefik
</CommandItem> </CommandItem>
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
router.push("/dashboard/docker"); router.push("/dashboard/docker");
setOpen(false); setOpen(false);
}} }}
> >
Docker Docker
</CommandItem> </CommandItem>
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
router.push("/dashboard/requests"); router.push("/dashboard/requests");
setOpen(false); setOpen(false);
}} }}
> >
Requests Requests
</CommandItem> </CommandItem>
</> </>
)} )}
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
router.push("/dashboard/settings/server"); router.push("/dashboard/settings/server");
setOpen(false); setOpen(false);
}} }}
> >
Settings Settings
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
</CommandDialog> </CommandDialog>
</div> </div>
); );
}; };

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

@@ -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,23 +399,47 @@ export const AddNotification = () => {
)} )}
{type === "discord" && ( {type === "discord" && (
<FormField <>
control={form.control} <FormField
name="webhookUrl" control={form.control}
render={({ field }) => ( name="webhookUrl"
<FormItem> render={({ field }) => (
<FormLabel>Webhook URL</FormLabel> <FormItem>
<FormControl> <FormLabel>Webhook URL</FormLabel>
<Input <FormControl>
placeholder="https://discord.com/api/webhooks/123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZ" <Input
{...field} placeholder="https://discord.com/api/webhooks/123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZ"
/> {...field}
</FormControl> />
</FormControl>
<FormMessage /> <FormMessage />
</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

@@ -29,7 +29,7 @@ export const DeleteNotification = ({ notificationId }: Props) => {
size="icon" size="icon"
className="h-9 w-9 group hover:bg-red-500/10" className="h-9 w-9 group hover:bg-red-500/10"
isLoading={isLoading} isLoading={isLoading}
> >
<Trash2 className="size-4 text-muted-foreground group-hover:text-red-500" /> <Trash2 className="size-4 text-muted-foreground group-hover:text-red-500" />
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>

View File

@@ -40,58 +40,60 @@ 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-1 xl:grid-cols-2 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 rounded-xl p-4 transition-colors dark:bg-zinc-900/50 hover:bg-zinc-900 border border-zinc-800/50" 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 items-center gap-4"> <div className="flex items-center gap-4">
{notification.notificationType === "slack" && ( {notification.notificationType === "slack" && (
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-500/10"> <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" /> <SlackIcon className="h-6 w-6 text-indigo-400" />
</div> </div>
)} )}
{notification.notificationType === "telegram" && ( {notification.notificationType === "telegram" && (
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-cyan-500/10"> <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" /> <TelegramIcon className="h-6 w-6 text-indigo-400" />
</div> </div>
)} )}
{notification.notificationType === "discord" && ( {notification.notificationType === "discord" && (
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-500/10"> <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" /> <DiscordIcon className="h-6 w-6 text-indigo-400" />
</div> </div>
)} )}
{notification.notificationType === "email" && ( {notification.notificationType === "email" && (
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-zinc-500/10"> <div className="flex h-12 w-12 items-center justify-center rounded-lg bg-zinc-500/10">
<Mail className="h-6 w-6 text-indigo-400" /> <Mail className="h-6 w-6 text-indigo-400" />
</div> </div>
)} )}
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-sm font-medium text-zinc-300"> <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"> <span className="text-xs font-medium text-muted-foreground">
{notification.notificationType?.[0]?.toUpperCase() + notification.notificationType?.slice(1)} notification {notification.notificationType?.[0]?.toUpperCase() +
</span> notification.notificationType?.slice(1)}{" "}
</div> notification
</div> </span>
<div className="flex items-center gap-2"> </div>
<UpdateNotification </div>
notificationId={notification.notificationId} <div className="flex items-center gap-2">
/> <UpdateNotification
<DeleteNotification notificationId={notification.notificationId}
notificationId={notification.notificationId} />
/> <DeleteNotification
</div> notificationId={notification.notificationId}
/>
</div>
</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> </div>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -28,7 +28,7 @@ import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Mail, Pen } 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,9 +220,11 @@ 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
size="icon" variant="ghost"
className="h-9 w-9"> 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" /> <Pen className="size-4 text-muted-foreground" />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
@@ -358,23 +362,46 @@ export const UpdateNotification = ({ notificationId }: Props) => {
)} )}
{type === "discord" && ( {type === "discord" && (
<FormField <>
control={form.control} <FormField
name="webhookUrl" control={form.control}
render={({ field }) => ( name="webhookUrl"
<FormItem> render={({ field }) => (
<FormLabel>Webhook URL</FormLabel> <FormItem>
<FormControl> <FormLabel>Webhook URL</FormLabel>
<Input <FormControl>
placeholder="https://discord.com/api/webhooks/123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZ" <Input
{...field} placeholder="https://discord.com/api/webhooks/123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZ"
/> {...field}
</FormControl> />
</FormControl>
<FormMessage /> <FormMessage />
</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" && (
<> <>
@@ -669,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(),

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,8 +25,8 @@ 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 { ShowModalLogs } from "../../web-server/show-modal-logs";
import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports"; import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports";
import { ShowModalLogs } from "../../web-server/show-modal-logs";
interface Props { interface Props {
serverId?: string; serverId?: string;

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

@@ -1,11 +1,23 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -13,60 +25,48 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import type React from "react"; import type React from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod";
/**
* Props for the ManageTraefikPorts component
* @interface Props
* @property {React.ReactNode} children - The trigger element that opens the ports management modal
* @property {string} [serverId] - Optional ID of the server whose ports are being managed
*/
interface Props { interface Props {
children: React.ReactNode; children: React.ReactNode;
serverId?: string; serverId?: string;
} }
/** const PortSchema = z.object({
* Represents a port mapping configuration for Traefik targetPort: z.number().min(1, "Target port is required"),
* @interface AdditionalPort publishedPort: z.number().min(1, "Published port is required"),
* @property {number} targetPort - The internal port that the service is listening on publishMode: z.enum(["ingress", "host"]),
* @property {number} publishedPort - The external port that will be exposed });
* @property {"ingress" | "host"} publishMode - The Docker Swarm publish mode:
* - "host": Publishes the port directly on the host const TraefikPortsSchema = z.object({
* - "ingress": Publishes the port through the Swarm routing mesh ports: z.array(PortSchema),
*/ });
interface AdditionalPort {
targetPort: number; type TraefikPortsForm = z.infer<typeof TraefikPortsSchema>;
publishedPort: number;
publishMode: "ingress" | "host";
}
/**
* ManageTraefikPorts is a component that provides a modal interface for managing
* additional port mappings for Traefik in a Docker Swarm environment.
*
* Features:
* - Add, remove, and edit port mappings
* - Configure target port, published port, and publish mode for each mapping
* - Persist port configurations through API calls
*
* @component
* @example
* ```tsx
* <ManageTraefikPorts serverId="server-123">
* <Button>Manage Ports</Button>
* </ManageTraefikPorts>
* ```
*/
export const ManageTraefikPorts = ({ children, serverId }: Props) => { export const ManageTraefikPorts = ({ children, serverId }: Props) => {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [additionalPorts, setAdditionalPorts] = useState<AdditionalPort[]>([]);
const form = useForm<TraefikPortsForm>({
resolver: zodResolver(TraefikPortsSchema),
defaultValues: {
ports: [],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "ports",
});
const { data: currentPorts, refetch: refetchPorts } = const { data: currentPorts, refetch: refetchPorts } =
api.settings.getTraefikPorts.useQuery({ api.settings.getTraefikPorts.useQuery({
@@ -82,22 +82,19 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
useEffect(() => { useEffect(() => {
if (currentPorts) { if (currentPorts) {
setAdditionalPorts(currentPorts); form.reset({ ports: currentPorts });
} }
}, [currentPorts]); }, [currentPorts, form]);
const handleAddPort = () => { const handleAddPort = () => {
setAdditionalPorts([ append({ targetPort: 0, publishedPort: 0, publishMode: "host" });
...additionalPorts,
{ targetPort: 0, publishedPort: 0, publishMode: "host" },
]);
}; };
const handleUpdatePorts = async () => { const onSubmit = async (data: TraefikPortsForm) => {
try { try {
await updatePorts({ await updatePorts({
serverId, serverId,
additionalPorts, additionalPorts: data.ports,
}); });
toast.success(t("settings.server.webServer.traefik.portsUpdated")); toast.success(t("settings.server.webServer.traefik.portsUpdated"));
setOpen(false); setOpen(false);
@@ -110,121 +107,197 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
<> <>
<div onClick={() => setOpen(true)}>{children}</div> <div onClick={() => setOpen(true)}>{children}</div>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-2xl"> <DialogContent className="sm:max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle className="flex items-center gap-2 text-xl">
{t("settings.server.webServer.traefik.managePorts")} {t("settings.server.webServer.traefik.managePorts")}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription className="text-base w-full">
{t("settings.server.webServer.traefik.managePortsDescription")} <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> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4">
{additionalPorts.map((port, index) => (
<div
key={index}
className="grid grid-cols-[120px_120px_minmax(120px,1fr)_80px] gap-4 items-end"
>
<div className="space-y-2">
<Label htmlFor={`target-port-${index}`}>
{t("settings.server.webServer.traefik.targetPort")}
</Label>
<input
id={`target-port-${index}`}
type="number"
value={port.targetPort}
onChange={(e) => {
const newPorts = [...additionalPorts];
if (newPorts[index]) { <Form {...form}>
newPorts[index].targetPort = Number.parseInt( <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
e.target.value, <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>
)}
/>
setAdditionalPorts(newPorts); <FormField
}} control={form.control}
className="w-full rounded border p-2" name={`ports.${index}.publishedPort`}
/> render={({ field }) => (
</div> <FormItem>
<div className="space-y-2"> <FormLabel className="text-sm font-medium text-muted-foreground">
<Label htmlFor={`published-port-${index}`}> {t(
{t("settings.server.webServer.traefik.publishedPort")} "settings.server.webServer.traefik.publishedPort",
</Label> )}
<input </FormLabel>
id={`published-port-${index}`} <FormControl>
type="number" <Input
value={port.publishedPort} type="number"
onChange={(e) => { {...field}
const newPorts = [...additionalPorts]; onChange={(e) =>
if (newPorts[index]) { field.onChange(Number(e.target.value))
newPorts[index].publishedPort = Number.parseInt( }
e.target.value, className="w-full dark:bg-black"
); placeholder="e.g. 80"
} />
setAdditionalPorts(newPorts); </FormControl>
}} <FormMessage />
className="w-full rounded border p-2" </FormItem>
/> )}
</div> />
<div className="space-y-2">
<Label htmlFor={`publish-mode-${index}`}>
{t("settings.server.webServer.traefik.publishMode")}
</Label>
<Select
value={port.publishMode}
onValueChange={(value: "ingress" | "host") => {
const newPorts = [...additionalPorts];
if (newPorts[index]) { <FormField
newPorts[index].publishMode = value; control={form.control}
} name={`ports.${index}.publishMode`}
setAdditionalPorts(newPorts); render={({ field }) => (
}} <FormItem>
> <FormLabel className="text-sm font-medium text-muted-foreground">
<SelectTrigger {t(
id={`publish-mode-${index}`} "settings.server.webServer.traefik.publishMode",
className="w-full" )}
> </FormLabel>
<SelectValue /> <Select
</SelectTrigger> onValueChange={field.onChange}
<SelectContent> value={field.value}
<SelectItem value="host">Host</SelectItem> >
<SelectItem value="ingress">Ingress</SelectItem> <FormControl>
</SelectContent> <SelectTrigger className="dark:bg-black">
</Select> <SelectValue />
</div> </SelectTrigger>
<div> </FormControl>
<Button <SelectContent>
onClick={() => { <SelectItem value="host">
const newPorts = additionalPorts.filter( Host Mode
(_, i) => i !== index, </SelectItem>
); <SelectItem value="ingress">
setAdditionalPorts(newPorts); Ingress Mode
}} </SelectItem>
variant="destructive" </SelectContent>
size="sm" </Select>
> <FormMessage />
Remove </FormItem>
</Button> )}
</div> />
<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> </div>
))}
<div className="mt-4 flex justify-between"> <DialogFooter>
<Button onClick={handleAddPort} variant="outline" size="sm"> <Button
{t("settings.server.webServer.traefik.addPort")} type="submit"
</Button> variant="default"
<Button className="text-sm"
onClick={handleUpdatePorts} isLoading={isLoading}
size="sm" >
disabled={isLoading} Save
> </Button>
Save </DialogFooter>
</Button> </form>
</div> </Form>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </>
); );
}; };
export default ManageTraefikPorts;

View File

@@ -91,7 +91,11 @@ export const ShowModalLogs = ({ appName, children, serverId }: Props) => {
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
<DockerLogsId containerId={containerId || ""} serverId={serverId} /> <DockerLogsId
containerId={containerId || ""}
serverId={serverId}
runType="native"
/>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -0,0 +1,245 @@
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 { ShowDockerModalStackLogs } from "../../docker/logs/show-docker-modal-stack-logs";
export interface ApplicationList {
ID: string;
Image: string;
Mode: string;
Name: string;
Ports: string;
Replicas: string;
CurrentState: string;
DesiredState: string;
Error: string;
Node: string;
serverId: 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 className="w-[10rem]">{row.getValue("Errors")}</div>;
},
},
{
accessorKey: "Logs",
accessorFn: (row) => row.Error,
header: ({ column }) => {
return <span>Logs</span>;
},
cell: ({ row }) => {
return (
<span className="w-[10rem]">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<ShowDockerModalStackLogs
containerId={row.original.ID}
serverId={row.original.serverId}
>
View Logs
</ShowDockerModalStackLogs>
</DropdownMenuContent>
</DropdownMenu>
</span>
);
},
},
];

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,104 @@
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 { type ApplicationList, columns } from "./columns";
import { DataTable } from "./data-table";
interface Props {
serverId?: 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,
serverId: serverId || "",
}));
});
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

@@ -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

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

View File

@@ -0,0 +1 @@
ALTER TABLE "mongo" ADD COLUMN "replicaSets" boolean DEFAULT false;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -365,6 +365,20 @@
"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
},
{
"idx": 53,
"version": "6",
"when": 1735118844878,
"tag": "0053_broken_kulan_gath",
"breakpoints": true
} }
] ]
} }

View File

@@ -13,6 +13,8 @@ export enum Languages {
Portuguese = "pt-br", Portuguese = "pt-br",
Italian = "it", Italian = "it",
Japanese = "ja", Japanese = "ja",
Spanish = "es",
Norwegian = "no",
} }
export type Language = keyof typeof Languages; export type Language = keyof typeof Languages;

View File

@@ -1,6 +1,6 @@
{ {
"name": "dokploy", "name": "dokploy",
"version": "v0.15.1", "version": "v0.16.0",
"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

@@ -6,6 +6,7 @@ import { ShowDomainsCompose } from "@/components/dashboard/compose/domains/show-
import { ShowEnvironmentCompose } from "@/components/dashboard/compose/enviroment/show"; import { ShowEnvironmentCompose } from "@/components/dashboard/compose/enviroment/show";
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show"; import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show"; import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
import { ShowDockerLogsStack } from "@/components/dashboard/compose/logs/show-stack";
import { ShowMonitoringCompose } from "@/components/dashboard/compose/monitoring/show"; import { ShowMonitoringCompose } from "@/components/dashboard/compose/monitoring/show";
import { UpdateCompose } from "@/components/dashboard/compose/update-compose"; import { UpdateCompose } from "@/components/dashboard/compose/update-compose";
import { ProjectLayout } from "@/components/layouts/project-layout"; import { ProjectLayout } from "@/components/layouts/project-layout";
@@ -251,11 +252,18 @@ const Service = (
<TabsContent value="logs"> <TabsContent value="logs">
<div className="flex flex-col gap-4 pt-2.5"> <div className="flex flex-col gap-4 pt-2.5">
<ShowDockerLogsCompose {data?.composeType === "docker-compose" ? (
serverId={data?.serverId || ""} <ShowDockerLogsCompose
appName={data?.appName || ""} serverId={data?.serverId || ""}
appType={data?.composeType || "docker-compose"} appName={data?.appName || ""}
/> appType={data?.composeType || "docker-compose"}
/>
) : (
<ShowDockerLogsStack
serverId={data?.serverId || ""}
appName={data?.appName || ""}
/>
)}
</div> </div>
</TabsContent> </TabsContent>

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,8 +17,8 @@
"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 Ports", "settings.server.webServer.traefik.managePorts": "Additional Port Mappings",
"settings.server.webServer.traefik.managePortsDescription": "Add or remove additional ports for Traefik", "settings.server.webServer.traefik.managePortsDescription": "Add or remove additional ports for Traefik",
"settings.server.webServer.traefik.targetPort": "Target Port", "settings.server.webServer.traefik.targetPort": "Target Port",
"settings.server.webServer.traefik.publishedPort": "Published Port", "settings.server.webServer.traefik.publishedPort": "Published Port",

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,52 @@
{
"settings.common.save": "Guardar",
"settings.server.domain.title": "Dominio del Servidor",
"settings.server.domain.description": "Añade un dominio a tu aplicación de servidor.",
"settings.server.domain.form.domain": "Dominio",
"settings.server.domain.form.letsEncryptEmail": "Correo de Let's Encrypt",
"settings.server.domain.form.certificate.label": "Proveedor de Certificado",
"settings.server.domain.form.certificate.placeholder": "Selecciona un certificado",
"settings.server.domain.form.certificateOptions.none": "Ninguno",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
"settings.server.webServer.title": "Servidor Web",
"settings.server.webServer.description": "Recarga o limpia el servidor web.",
"settings.server.webServer.actions": "Acciones",
"settings.server.webServer.reload": "Recargar",
"settings.server.webServer.watchLogs": "Ver registros",
"settings.server.webServer.updateServerIp": "Actualizar IP del Servidor",
"settings.server.webServer.server.label": "Servidor",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Modificar Entorno",
"settings.server.webServer.traefik.managePorts": "Asignación Adicional de Puertos",
"settings.server.webServer.traefik.managePortsDescription": "Añadir o eliminar puertos adicionales para Traefik",
"settings.server.webServer.traefik.targetPort": "Puerto de Destino",
"settings.server.webServer.traefik.publishedPort": "Puerto Publicado",
"settings.server.webServer.traefik.addPort": "Añadir Puerto",
"settings.server.webServer.traefik.portsUpdated": "Puertos actualizados correctamente",
"settings.server.webServer.traefik.portsUpdateError": "Error al actualizar los puertos",
"settings.server.webServer.traefik.publishMode": "Modo de Publicación",
"settings.server.webServer.storage.label": "Espacio",
"settings.server.webServer.storage.cleanUnusedImages": "Limpiar imágenes no utilizadas",
"settings.server.webServer.storage.cleanUnusedVolumes": "Limpiar volúmenes no utilizados",
"settings.server.webServer.storage.cleanStoppedContainers": "Limpiar contenedores detenidos",
"settings.server.webServer.storage.cleanDockerBuilder": "Limpiar Constructor de Docker y Sistema",
"settings.server.webServer.storage.cleanMonitoring": "Limpiar Monitoreo",
"settings.server.webServer.storage.cleanAll": "Limpiar todo",
"settings.profile.title": "Cuenta",
"settings.profile.description": "Cambia los detalles de tu perfil aquí.",
"settings.profile.email": "Correo electrónico",
"settings.profile.password": "Contraseña",
"settings.profile.avatar": "Avatar",
"settings.appearance.title": "Apariencia",
"settings.appearance.description": "Personaliza el tema de tu panel.",
"settings.appearance.theme": "Tema",
"settings.appearance.themeDescription": "Selecciona un tema para tu panel",
"settings.appearance.themes.light": "Claro",
"settings.appearance.themes.dark": "Oscuro",
"settings.appearance.themes.system": "Sistema",
"settings.appearance.language": "Idioma",
"settings.appearance.languageDescription": "Selecciona un idioma para tu panel"
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,52 @@
{
"settings.common.save": "Lagre",
"settings.server.domain.title": "Serverdomene",
"settings.server.domain.description": "Legg til et domene i serverapplikasjonen din.",
"settings.server.domain.form.domain": "Domene",
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt Epost",
"settings.server.domain.form.certificate.label": "Sertifikatleverandør",
"settings.server.domain.form.certificate.placeholder": "Velg et sertifikat",
"settings.server.domain.form.certificateOptions.none": "Ingen",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
"settings.server.webServer.title": "Webserver",
"settings.server.webServer.description": "Last på nytt eller rens webserveren.",
"settings.server.webServer.actions": "Handlinger",
"settings.server.webServer.reload": "Last på nytt",
"settings.server.webServer.watchLogs": "Se logger",
"settings.server.webServer.updateServerIp": "Oppdater server-IP",
"settings.server.webServer.server.label": "Server",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Endre miljø",
"settings.server.webServer.traefik.managePorts": "Ytterligere portkartlegginger",
"settings.server.webServer.traefik.managePortsDescription": "Legg til eller fjern flere porter for Traefik",
"settings.server.webServer.traefik.targetPort": "Målport",
"settings.server.webServer.traefik.publishedPort": "Publisert port",
"settings.server.webServer.traefik.addPort": "Legg til port",
"settings.server.webServer.traefik.portsUpdated": "Portene ble oppdatert",
"settings.server.webServer.traefik.portsUpdateError": "Kunne ikke oppdatere portene",
"settings.server.webServer.traefik.publishMode": "Publiseringsmodus",
"settings.server.webServer.storage.label": "Lagring",
"settings.server.webServer.storage.cleanUnusedImages": "Rens ubrukte bilder",
"settings.server.webServer.storage.cleanUnusedVolumes": "Rens ubrukte volumer",
"settings.server.webServer.storage.cleanStoppedContainers": "Rens stoppete containere",
"settings.server.webServer.storage.cleanDockerBuilder": "Rens Docker Builder og System",
"settings.server.webServer.storage.cleanMonitoring": "Rens overvåking",
"settings.server.webServer.storage.cleanAll": "Rens alt",
"settings.profile.title": "Konto",
"settings.profile.description": "Endre detaljene for profilen din her.",
"settings.profile.email": "Epost",
"settings.profile.password": "Passord",
"settings.profile.avatar": "Avatar",
"settings.appearance.title": "Utseende",
"settings.appearance.description": "Tilpass temaet for dashbordet ditt.",
"settings.appearance.theme": "Tema",
"settings.appearance.themeDescription": "Velg et tema for dashbordet ditt",
"settings.appearance.themes.light": "Lys",
"settings.appearance.themes.dark": "Mørk",
"settings.appearance.themes.system": "System",
"settings.appearance.language": "Språk",
"settings.appearance.languageDescription": "Velg et språk for dashbordet ditt"
}

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

@@ -555,9 +555,9 @@ export const applicationRouter = createTRPCRouter({
}); });
} }
updateApplication(input.applicationId as string, { await updateApplication(input.applicationId as string, {
sourceType: "drop", sourceType: "drop",
dropBuildPath: input.dropBuildPath, dropBuildPath: input.dropBuildPath || "",
}); });
await unzipDrop(zipFile, app); await unzipDrop(zipFile, app);

View File

@@ -505,7 +505,7 @@ export const sendDiscordNotificationWelcome = async (newAdmin: Auth) => {
webhookUrl: process.env.DISCORD_WEBHOOK_URL || "", webhookUrl: process.env.DISCORD_WEBHOOK_URL || "",
}, },
{ {
title: " New User Registered", title: "New User Registered",
color: 0x00ff00, color: 0x00ff00,
fields: [ fields: [
{ {

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

@@ -4,6 +4,8 @@ import {
getContainers, getContainers,
getContainersByAppLabel, getContainersByAppLabel,
getContainersByAppNameMatch, getContainersByAppNameMatch,
getServiceContainersByAppName,
getStackContainersByAppName,
} from "@dokploy/server"; } from "@dokploy/server";
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
@@ -68,4 +70,26 @@ export const dockerRouter = createTRPCRouter({
.query(async ({ input }) => { .query(async ({ input }) => {
return await getContainersByAppLabel(input.appName, input.serverId); return await getContainersByAppLabel(input.appName, input.serverId);
}), }),
getStackContainersByAppName: protectedProcedure
.input(
z.object({
appName: z.string().min(1),
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
return await getStackContainersByAppName(input.appName, input.serverId);
}),
getServiceContainersByAppName: protectedProcedure
.input(
z.object({
appName: z.string().min(1),
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
return await getServiceContainersByAppName(input.appName, input.serverId);
}),
}); });

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

@@ -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

@@ -34,6 +34,7 @@ export const setupDockerContainerLogsWebSocketServer = (
const search = url.searchParams.get("search"); const search = url.searchParams.get("search");
const since = url.searchParams.get("since"); const since = url.searchParams.get("since");
const serverId = url.searchParams.get("serverId"); const serverId = url.searchParams.get("serverId");
const runType = url.searchParams.get("runType");
const { user, session } = await validateWebSocketRequest(req); const { user, session } = await validateWebSocketRequest(req);
if (!containerId) { if (!containerId) {
@@ -53,7 +54,9 @@ export const setupDockerContainerLogsWebSocketServer = (
const client = new Client(); const client = new Client();
client client
.once("ready", () => { .once("ready", () => {
const baseCommand = `docker container logs --timestamps --tail ${tail} ${ const baseCommand = `docker ${runType === "swarm" ? "service" : "container"} logs --timestamps ${
runType === "swarm" ? "--raw" : ""
} --tail ${tail} ${
since === "all" ? "" : `--since ${since}` since === "all" ? "" : `--since ${since}`
} --follow ${containerId}`; } --follow ${containerId}`;
const escapedSearch = search ? search.replace(/'/g, "'\\''") : ""; const escapedSearch = search ? search.replace(/'/g, "'\\''") : "";
@@ -97,7 +100,9 @@ export const setupDockerContainerLogsWebSocketServer = (
}); });
} else { } else {
const shell = getShell(); const shell = getShell();
const baseCommand = `docker container logs --timestamps --tail ${tail} ${ const baseCommand = `docker ${runType === "swarm" ? "service" : "container"} logs --timestamps ${
runType === "swarm" ? "--raw" : ""
} --tail ${tail} ${
since === "all" ? "" : `--since ${since}` since === "all" ? "" : `--since ${since}`
} --follow ${containerId}`; } --follow ${containerId}`;
const command = search const command = search

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

@@ -1,22 +1,22 @@
import { import {
type DomainSchema, type DomainSchema,
type Schema, type Schema,
type Template, type Template,
generateRandomDomain, generateRandomDomain,
} from "../utils"; } from "../utils";
export function generate(schema: Schema): Template { export function generate(schema: Schema): Template {
const randomDomain = generateRandomDomain(schema); const randomDomain = generateRandomDomain(schema);
const domains: DomainSchema[] = [ const domains: DomainSchema[] = [
{ {
host: randomDomain, host: randomDomain,
port: 6610, port: 6610,
serviceName: "onedev", serviceName: "onedev",
}, },
]; ];
return { return {
domains, domains,
}; };
} }

View File

@@ -1,44 +1,44 @@
import { import {
generateHash, type DomainSchema,
generateRandomDomain, type Schema,
generateBase64, type Template,
type Template, generateBase64,
type Schema, generateHash,
type DomainSchema, generateRandomDomain,
} from "../utils"; } from "../utils";
export function generate(schema: Schema): Template { export function generate(schema: Schema): Template {
const mainDomain = generateRandomDomain(schema); const mainDomain = generateRandomDomain(schema);
const secretBase = generateBase64(64); const secretBase = generateBase64(64);
const domains: DomainSchema[] = [ const domains: DomainSchema[] = [
{ {
host: mainDomain, host: mainDomain,
port: 3000, port: 3000,
serviceName: "unsend", serviceName: "unsend",
}, },
]; ];
const envs = [ const envs = [
"REDIS_URL=redis://unsend-redis-prod:6379", "REDIS_URL=redis://unsend-redis-prod:6379",
"POSTGRES_USER=postgres", "POSTGRES_USER=postgres",
"POSTGRES_PASSWORD=postgres", "POSTGRES_PASSWORD=postgres",
"POSTGRES_DB=unsend", "POSTGRES_DB=unsend",
"DATABASE_URL=postgresql://postgres:postgres@unsend-db-prod:5432/unsend", "DATABASE_URL=postgresql://postgres:postgres@unsend-db-prod:5432/unsend",
"NEXTAUTH_URL=http://localhost:3000", "NEXTAUTH_URL=http://localhost:3000",
`NEXTAUTH_SECRET=${secretBase}`, `NEXTAUTH_SECRET=${secretBase}`,
"GITHUB_ID='Fill'", "GITHUB_ID='Fill'",
"GITHUB_SECRET='Fill'", "GITHUB_SECRET='Fill'",
"AWS_DEFAULT_REGION=us-east-1", "AWS_DEFAULT_REGION=us-east-1",
"AWS_SECRET_KEY='Fill'", "AWS_SECRET_KEY='Fill'",
"AWS_ACCESS_KEY='Fill'", "AWS_ACCESS_KEY='Fill'",
"DOCKER_OUTPUT=1", "DOCKER_OUTPUT=1",
"API_RATE_LIMIT=1", "API_RATE_LIMIT=1",
"DISCORD_WEBHOOK_URL=", "DISCORD_WEBHOOK_URL=",
]; ];
return { return {
envs, envs,
domains, domains,
}; };
} }

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

@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { integer, pgTable, text } from "drizzle-orm/pg-core"; import { boolean, integer, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod"; import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { z } from "zod"; import { z } from "zod";
@@ -43,6 +43,7 @@ export const mongo = pgTable("mongo", {
serverId: text("serverId").references(() => server.serverId, { serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade", onDelete: "cascade",
}), }),
replicaSets: boolean("replicaSets").default(false),
}); });
export const mongoRelations = relations(mongo, ({ one, many }) => ({ export const mongoRelations = relations(mongo, ({ one, many }) => ({
@@ -77,6 +78,7 @@ const createSchema = createInsertSchema(mongo, {
externalPort: z.number(), externalPort: z.number(),
description: z.string().optional(), description: z.string().optional(),
serverId: z.string().optional(), serverId: z.string().optional(),
replicaSets: z.boolean().default(false),
}); });
export const apiCreateMongo = createSchema export const apiCreateMongo = createSchema
@@ -89,6 +91,7 @@ export const apiCreateMongo = createSchema
databaseUser: true, databaseUser: true,
databasePassword: true, databasePassword: true,
serverId: true, serverId: true,
replicaSets: true,
}) })
.required(); .required();

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,9 +182,13 @@ export const apiUpdateDiscord = apiCreateDiscord.partial().extend({
adminId: z.string().optional(), adminId: z.string().optional(),
}); });
export const apiTestDiscordConnection = apiCreateDiscord.pick({ export const apiTestDiscordConnection = apiCreateDiscord
webhookUrl: true, .pick({
}); webhookUrl: true,
})
.extend({
decoration: z.boolean().optional(),
});
export const apiCreateEmail = notificationsSchema export const apiCreateEmail = notificationsSchema
.pick({ .pick({

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,34 +46,31 @@ 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);
if (!valid) { const valid = await validUniqueServerAppName(appName);
throw new TRPCError({ if (!valid) {
code: "CONFLICT", throw new TRPCError({
message: "Application with this 'AppName' already exists", code: "CONFLICT",
}); message: "Application with this 'AppName' already exists",
} });
} }
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
@@ -81,6 +78,7 @@ export const createApplication = async (
.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,17 +52,14 @@ 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);
if (!valid) { const valid = await validUniqueServerAppName(appName);
throw new TRPCError({ if (!valid) {
code: "CONFLICT", throw new TRPCError({
message: "Service with this 'AppName' already exists", code: "CONFLICT",
}); message: "Service with this 'AppName' already exists",
} });
} }
const newDestination = await db const newDestination = await db
@@ -70,6 +67,7 @@ export const createCompose = async (input: typeof apiCreateCompose._type) => {
.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

@@ -157,6 +157,124 @@ export const getContainersByAppNameMatch = async (
return []; return [];
}; };
export const getStackContainersByAppName = async (
appName: string,
serverId?: string,
) => {
try {
let result: string[] = [];
const command = `docker stack ps ${appName} --format 'CONTAINER ID : {{.ID}} | Name: {{.Name}} | State: {{.DesiredState}} | Node: {{.Node}}'`;
if (serverId) {
const { stdout, stderr } = await execAsyncRemote(serverId, command);
if (stderr) {
return [];
}
if (!stdout) return [];
result = stdout.trim().split("\n");
} else {
const { stdout, stderr } = await execAsync(command);
if (stderr) {
return [];
}
if (!stdout) return [];
result = stdout.trim().split("\n");
}
const containers = result.map((line) => {
const parts = line.split(" | ");
const containerId = parts[0]
? parts[0].replace("CONTAINER ID : ", "").trim()
: "No container id";
const name = parts[1]
? parts[1].replace("Name: ", "").trim()
: "No container name";
const state = parts[2]
? parts[2].replace("State: ", "").trim().toLowerCase()
: "No state";
const node = parts[3]
? parts[3].replace("Node: ", "").trim()
: "No specific node";
return {
containerId,
name,
state,
node,
};
});
return containers || [];
} catch (error) {}
return [];
};
export const getServiceContainersByAppName = async (
appName: string,
serverId?: string,
) => {
try {
let result: string[] = [];
const command = `docker service ps ${appName} --format 'CONTAINER ID : {{.ID}} | Name: {{.Name}} | State: {{.DesiredState}} | Node: {{.Node}}'`;
if (serverId) {
const { stdout, stderr } = await execAsyncRemote(serverId, command);
if (stderr) {
return [];
}
if (!stdout) return [];
result = stdout.trim().split("\n");
} else {
const { stdout, stderr } = await execAsync(command);
if (stderr) {
return [];
}
if (!stdout) return [];
result = stdout.trim().split("\n");
}
const containers = result.map((line) => {
const parts = line.split(" | ");
const containerId = parts[0]
? parts[0].replace("CONTAINER ID : ", "").trim()
: "No container id";
const name = parts[1]
? parts[1].replace("Name: ", "").trim()
: "No container name";
const state = parts[2]
? parts[2].replace("State: ", "").trim().toLowerCase()
: "No state";
const node = parts[3]
? parts[3].replace("Node: ", "").trim()
: "No specific node";
return {
containerId,
name,
state,
node,
};
});
return containers || [];
} catch (error) {}
return [];
};
export const getContainersByAppLabel = async ( export const getContainersByAppLabel = async (
appName: string, appName: string,
serverId?: string, serverId?: string,
@@ -224,3 +342,123 @@ 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 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))
.filter((service) => !service.Name.startsWith("dokploy-"));
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 .}}' --no-trunc`;
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,17 +17,14 @@ 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);
if (!valid) { const valid = await validUniqueServerAppName(input.appName);
throw new TRPCError({ if (!valid) {
code: "CONFLICT", throw new TRPCError({
message: "Service with this 'AppName' already exists", code: "CONFLICT",
}); message: "Service with this 'AppName' already exists",
} });
} }
const newMariadb = await db const newMariadb = await db
@@ -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,17 +13,14 @@ 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);
if (!valid) { const valid = await validUniqueServerAppName(appName);
throw new TRPCError({ if (!valid) {
code: "CONFLICT", throw new TRPCError({
message: "Service with this 'AppName' already exists", code: "CONFLICT",
}); message: "Service with this 'AppName' already exists",
} });
} }
const newMongo = await db const newMongo = await db
@@ -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,18 +13,14 @@ 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(appName);
const valid = await validUniqueServerAppName(input.appName); if (!valid) {
throw new TRPCError({
if (!valid) { code: "CONFLICT",
throw new TRPCError({ message: "Service with this 'AppName' already exists",
code: "CONFLICT", });
message: "Service with this 'AppName' already exists",
});
}
} }
const newMysql = await db const newMysql = await db
@@ -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,17 +17,14 @@ 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);
if (!valid) { const valid = await validUniqueServerAppName(appName);
throw new TRPCError({ if (!valid) {
code: "CONFLICT", throw new TRPCError({
message: "Service with this 'AppName' already exists", code: "CONFLICT",
}); message: "Service with this 'AppName' already exists",
} });
} }
const newPostgres = await db const newPostgres = await db
@@ -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,17 +14,14 @@ 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);
if (!valid) { const valid = await validUniqueServerAppName(appName);
throw new TRPCError({ if (!valid) {
code: "CONFLICT", throw new TRPCError({
message: "Service with this 'AppName' already exists", code: "CONFLICT",
}); message: "Service with this 'AppName' already exists",
} });
} }
const newRedis = await db const newRedis = await db
@@ -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

@@ -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

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