diff --git a/apps/dokploy/components/auth/login-2fa.tsx b/apps/dokploy/components/auth/login-2fa.tsx index 7c4915fa..dcb004f1 100644 --- a/apps/dokploy/components/auth/login-2fa.tsx +++ b/apps/dokploy/components/auth/login-2fa.tsx @@ -87,7 +87,7 @@ export const Login2FA = ({ authId }: Props) => { )} - 2FA Setup + 2FA Login {
{ filteredLogs.length > 0 ? filteredLogs.map((log: LogLine, index: number) => ( { return ( - + diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx index 45451e78..898d412a 100644 --- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx @@ -1,212 +1,237 @@ -import { DateTooltip } from "@/components/shared/date-tooltip"; -import { StatusTooltip } from "@/components/shared/status-tooltip"; +import React from "react"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Switch } from "@/components/ui/switch"; -import { api } from "@/utils/api"; -import { Pencil, RocketIcon } from "lucide-react"; -import React, { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { ShowDeployment } from "../deployments/show-deployment"; + Clock, + GitBranch, + GitPullRequest, + Pencil, + RocketIcon, +} from "lucide-react"; import Link from "next/link"; import { ShowModalLogs } from "../../settings/web-server/show-modal-logs"; import { DialogAction } from "@/components/shared/dialog-action"; -import { AddPreviewDomain } from "./add-preview-domain"; -import { GithubIcon } from "@/components/icons/data-tools-icons"; -import { ShowPreviewSettings } from "./show-preview-settings"; +import { api } from "@/utils/api"; import { ShowPreviewBuilds } from "./show-preview-builds"; +import { DateTooltip } from "@/components/shared/date-tooltip"; +import { toast } from "sonner"; +import { StatusTooltip } from "@/components/shared/status-tooltip"; +import { AddPreviewDomain } from "./add-preview-domain"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { ShowPreviewSettings } from "./show-preview-settings"; interface Props { - applicationId: string; + applicationId: string; } export const ShowPreviewDeployments = ({ applicationId }: Props) => { - const [activeLog, setActiveLog] = useState(null); - const { data } = api.application.one.useQuery({ applicationId }); + const { data } = api.application.one.useQuery({ applicationId }); - const { mutateAsync: deletePreviewDeployment, isLoading } = - api.previewDeployment.delete.useMutation(); - const { data: previewDeployments, refetch: refetchPreviewDeployments } = - api.previewDeployment.all.useQuery( - { applicationId }, - { - enabled: !!applicationId, - }, - ); - // const [url, setUrl] = React.useState(""); - // useEffect(() => { - // setUrl(document.location.origin); - // }, []); + const { mutateAsync: deletePreviewDeployment, isLoading } = + api.previewDeployment.delete.useMutation(); - return ( - - -
- Preview Deployments - See all the preview deployments -
- {data?.isPreviewDeploymentsActive && ( - - )} -
- - {data?.isPreviewDeploymentsActive ? ( - <> -
- - Preview deployments are a way to test your application before it - is deployed to production. It will create a new deployment for - each pull request you create. - -
- {data?.previewDeployments?.length === 0 ? ( -
- - - No preview deployments found - -
- ) : ( -
- {previewDeployments?.map((previewDeployment) => { - const { deployments, domain } = previewDeployment; + const { data: previewDeployments, refetch: refetchPreviewDeployments } = + api.previewDeployment.all.useQuery( + { applicationId }, + { + enabled: !!applicationId, + } + ); - return ( -
-
-
- {deployments?.length === 0 ? ( -
- - No deployments found - -
- ) : ( -
- - {previewDeployment?.pullRequestTitle} - - -
- )} -
- {previewDeployment?.pullRequestTitle && ( -
- - Title: {previewDeployment?.pullRequestTitle} - -
- )} + const handleDeletePreviewDeployment = async (previewDeploymentId: string) => { + deletePreviewDeployment({ + previewDeploymentId: previewDeploymentId, + }) + .then(() => { + refetchPreviewDeployments(); + toast.success("Preview deployment deleted"); + }) + .catch((error) => { + toast.error(error.message); + }); + }; - {previewDeployment?.pullRequestURL && ( -
- - - Pull Request URL - -
- )} -
-
- Domain -
- - {domain?.host} - - - - -
-
-
+ return ( + + +
+ Preview Deployments + See all the preview deployments +
+ {data?.isPreviewDeploymentsActive && ( + + )} +
+ + {data?.isPreviewDeploymentsActive ? ( + <> +
+ + Preview deployments are a way to test your application before it + is deployed to production. It will create a new deployment for + each pull request you create. + +
+ {!previewDeployments?.length ? ( +
+ + + No preview deployments found + +
+ ) : ( +
+ {previewDeployments.map((previewDeployment) => ( +
+
+ + {previewDeployment.pullRequestTitle} + + + + {previewDeployment.previewStatus + ?.replace("running", "Running") + .replace("done", "Done") + .replace("error", "Error") + .replace("idle", "Idle") || "Idle"} + +
-
- {previewDeployment?.createdAt && ( -
- -
- )} - +
+
+ + {previewDeployment.domain?.host} + - - - + + + +
- { - deletePreviewDeployment({ - previewDeploymentId: - previewDeployment.previewDeploymentId, - }) - .then(() => { - refetchPreviewDeployments(); - toast.success("Preview deployment deleted"); - }) - .catch((error) => { - toast.error(error.message); - }); - }} - > - - -
-
-
- ); - })} -
- )} - - ) : ( -
- - - Preview deployments are disabled for this application, please - enable it - - -
- )} -
-
- ); +
+
+ + Branch: + + {previewDeployment.branch} + +
+
+ + Deployed: + + + +
+
+ + + +
+

+ Pull Request +

+
+ + + {previewDeployment.pullRequestTitle} + +
+
+
+ +
+
+ + + + + + + + handleDeletePreviewDeployment( + previewDeployment.previewDeploymentId + ) + } + > + + +
+
+
+ ))} +
+ )} + + ) : ( +
+ + + Preview deployments are disabled for this application, please + enable it + + +
+ )} +
+
+ ); }; diff --git a/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx b/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx index 44ce15c0..6d1b455f 100644 --- a/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx +++ b/apps/dokploy/components/dashboard/compose/advanced/add-command.tsx @@ -1,3 +1,4 @@ +import { AlertBlock } from "@/components/shared/alert-block"; import { Button } from "@/components/ui/button"; import { Card, @@ -91,7 +92,7 @@ export const AddCommandCompose = ({ composeId }: Props) => {
Run Command - Append a custom command to the compose file + Override a custom command to the compose file
@@ -101,6 +102,12 @@ export const AddCommandCompose = ({ composeId }: Props) => { onSubmit={form.handleSubmit(onSubmit)} className="grid w-full gap-4" > + + Modifying the default command may affect deployment stability, + impacting logs and monitoring. Proceed carefully and test + thoroughly. By default, the command starts with{" "} + docker. +
diff --git a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx index db1c774b..3f30c292 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -1,309 +1,291 @@ -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { api } from "@/utils/api"; import { Download as DownloadIcon, Loader2 } from "lucide-react"; import React, { useEffect, useRef } from "react"; +import { LineCountFilter } from "./line-count-filter"; +import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter"; +import { StatusLogsFilter } from "./status-logs-filter"; import { TerminalLine } from "./terminal-line"; import { type LogLine, getLogType, parseLogs } from "./utils"; interface Props { - containerId: string; - serverId?: string | null; + containerId: string; + serverId?: string | null; } -type TimeFilter = "all" | "1h" | "6h" | "24h" | "168h" | "720h"; -type TypeFilter = "all" | "error" | "warning" | "success" | "info" | "debug"; +export const priorities = [ + { + label: "Info", + value: "info", + }, + { + label: "Success", + value: "success", + }, + { + label: "Warning", + value: "warning", + }, + { + label: "Debug", + value: "debug", + }, + { + label: "Error", + value: "error", + }, +]; export const DockerLogsId: React.FC = ({ containerId, serverId }) => { - const { data } = api.docker.getConfig.useQuery( - { - containerId, - serverId: serverId ?? undefined, - }, - { - enabled: !!containerId, - } - ); + const { data } = api.docker.getConfig.useQuery( + { + containerId, + serverId: serverId ?? undefined, + }, + { + enabled: !!containerId, + }, + ); - const [rawLogs, setRawLogs] = React.useState(""); - const [filteredLogs, setFilteredLogs] = React.useState([]); - const [autoScroll, setAutoScroll] = React.useState(true); - const [lines, setLines] = React.useState(100); - const [search, setSearch] = React.useState(""); + const [rawLogs, setRawLogs] = React.useState(""); + const [filteredLogs, setFilteredLogs] = React.useState([]); + const [autoScroll, setAutoScroll] = React.useState(true); + const [lines, setLines] = React.useState(100); + const [search, setSearch] = React.useState(""); + const [showTimestamp, setShowTimestamp] = React.useState(true); + const [since, setSince] = React.useState("all"); + const [typeFilter, setTypeFilter] = React.useState([]); + const scrollRef = useRef(null); + const [isLoading, setIsLoading] = React.useState(false); - const [since, setSince] = React.useState("all"); - const [typeFilter, setTypeFilter] = React.useState("all"); - const scrollRef = useRef(null); - const [isLoading, setIsLoading] = React.useState(false); + const scrollToBottom = () => { + if (autoScroll && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }; - const scrollToBottom = () => { - if (autoScroll && scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }; + const handleScroll = () => { + if (!scrollRef.current) return; - const handleScroll = () => { - if (!scrollRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; + const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10; + setAutoScroll(isAtBottom); + }; - const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; - const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10; - setAutoScroll(isAtBottom); - }; + const handleSearch = (e: React.ChangeEvent) => { + setSearch(e.target.value || ""); + }; - const handleSearch = (e: React.ChangeEvent) => { - setSearch(e.target.value || ""); - }; + const handleLines = (lines: number) => { + setRawLogs(""); + setFilteredLogs([]); + setLines(lines); + }; - const handleLines = (e: React.ChangeEvent) => { - setRawLogs(""); - setFilteredLogs([]); - setLines(Number(e.target.value) || 1); - }; + const handleSince = (value: TimeFilter) => { + setRawLogs(""); + setFilteredLogs([]); + setSince(value); + }; - const handleSince = (value: TimeFilter) => { - setRawLogs(""); - setFilteredLogs([]); - setSince(value); - }; + useEffect(() => { + if (!containerId) return; - const handleTypeFilter = (value: TypeFilter) => { - setTypeFilter(value); - }; + let isCurrentConnection = true; + let noDataTimeout: NodeJS.Timeout; + setIsLoading(true); + setRawLogs(""); + setFilteredLogs([]); - useEffect(() => { - if (!containerId) return; - - let isCurrentConnection = true; - let noDataTimeout: NodeJS.Timeout; - setIsLoading(true); - setRawLogs(""); - setFilteredLogs([]); + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const params = new globalThis.URLSearchParams({ + containerId, + tail: lines.toString(), + since, + search, + }); - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const params = new globalThis.URLSearchParams({ - containerId, - tail: lines.toString(), - since, - search, - }); + if (serverId) { + params.append("serverId", serverId); + } - if (serverId) { - params.append("serverId", serverId); - } + const wsUrl = `${protocol}//${ + window.location.host + }/docker-container-logs?${params.toString()}`; + console.log("Connecting to WebSocket:", wsUrl); + const ws = new WebSocket(wsUrl); - const wsUrl = `${protocol}//${ - window.location.host - }/docker-container-logs?${params.toString()}`; - console.log("Connecting to WebSocket:", wsUrl); - const ws = new WebSocket(wsUrl); + const resetNoDataTimeout = () => { + if (noDataTimeout) clearTimeout(noDataTimeout); + noDataTimeout = setTimeout(() => { + if (isCurrentConnection) { + setIsLoading(false); + } + }, 2000); // Wait 2 seconds for data before showing "No logs found" + }; - const resetNoDataTimeout = () => { - if (noDataTimeout) clearTimeout(noDataTimeout); - noDataTimeout = setTimeout(() => { - if (isCurrentConnection) { - setIsLoading(false); - } - }, 2000); // Wait 2 seconds for data before showing "No logs found" - }; + ws.onopen = () => { + if (!isCurrentConnection) { + ws.close(); + return; + } + console.log("WebSocket connected"); + resetNoDataTimeout(); + }; - ws.onopen = () => { - if (!isCurrentConnection) { - ws.close(); - return; - } - console.log("WebSocket connected"); - resetNoDataTimeout(); - }; + ws.onmessage = (e) => { + if (!isCurrentConnection) return; + setRawLogs((prev) => prev + e.data); + setIsLoading(false); + if (noDataTimeout) clearTimeout(noDataTimeout); + }; - ws.onmessage = (e) => { - if (!isCurrentConnection) return; - setRawLogs((prev) => prev + e.data); - setIsLoading(false); - if (noDataTimeout) clearTimeout(noDataTimeout); - }; + ws.onerror = (error) => { + if (!isCurrentConnection) return; + console.error("WebSocket error:", error); + setIsLoading(false); + if (noDataTimeout) clearTimeout(noDataTimeout); + }; - ws.onerror = (error) => { - if (!isCurrentConnection) return; - console.error("WebSocket error:", error); - setIsLoading(false); - if (noDataTimeout) clearTimeout(noDataTimeout); - }; + ws.onclose = (e) => { + if (!isCurrentConnection) return; + console.log("WebSocket closed:", e.reason); + setIsLoading(false); + if (noDataTimeout) clearTimeout(noDataTimeout); + }; - ws.onclose = (e) => { - if (!isCurrentConnection) return; - console.log("WebSocket closed:", e.reason); - setIsLoading(false); - if (noDataTimeout) clearTimeout(noDataTimeout); - }; + return () => { + isCurrentConnection = false; + if (noDataTimeout) clearTimeout(noDataTimeout); + if (ws.readyState === WebSocket.OPEN) { + ws.close(); + } + }; + }, [containerId, serverId, lines, search, since]); - return () => { - isCurrentConnection = false; - if (noDataTimeout) clearTimeout(noDataTimeout); - if (ws.readyState === WebSocket.OPEN) { - ws.close(); - } - }; - }, [containerId, serverId, lines, search, since]); + const handleDownload = () => { + const logContent = filteredLogs + .map( + ({ timestamp, message }: { timestamp: Date | null; message: string }) => + `${timestamp?.toISOString() || "No timestamp"} ${message}`, + ) + .join("\n"); - const handleDownload = () => { - const logContent = filteredLogs - .map( - ({ timestamp, message }: { timestamp: Date | null; message: string }) => - `${timestamp?.toISOString() || "No timestamp"} ${message}` - ) - .join("\n"); + const blob = new Blob([logContent], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + const appName = data.Name.replace("/", "") || "app"; + const isoDate = new Date().toISOString(); + a.href = url; + a.download = `${appName}-${isoDate.slice(0, 10).replace(/-/g, "")}_${isoDate + .slice(11, 19) + .replace(/:/g, "")}.log.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; - const blob = new Blob([logContent], { type: "text/plain" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - const appName = data.Name.replace("/", "") || "app"; - const isoDate = new Date().toISOString(); - a.href = url; - a.download = `${appName}-${isoDate.slice(0, 10).replace(/-/g, "")}_${isoDate - .slice(11, 19) - .replace(/:/g, "")}.log.txt`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }; + const handleFilter = (logs: LogLine[]) => { + return logs.filter((log) => { + const logType = getLogType(log.message).type; - const handleFilter = (logs: LogLine[]) => { - return logs.filter((log) => { - const logType = getLogType(log.message).type; + if (typeFilter.length === 0) { + return true; + } - const matchesType = typeFilter === "all" || logType === typeFilter; + return typeFilter.includes(logType); + }); + }; - return matchesType; - }); - }; + useEffect(() => { + setRawLogs(""); + setFilteredLogs([]); + }, [containerId]); - useEffect(() => { - setRawLogs(""); - setFilteredLogs([]); - }, [containerId]); + useEffect(() => { + const logs = parseLogs(rawLogs); + const filtered = handleFilter(logs); + setFilteredLogs(filtered); + }, [rawLogs, search, lines, since, typeFilter]); - useEffect(() => { - const logs = parseLogs(rawLogs); - const filtered = handleFilter(logs); - setFilteredLogs(filtered); - }, [rawLogs, search, lines, since, typeFilter]); + useEffect(() => { + scrollToBottom(); - useEffect(() => { - scrollToBottom(); + if (autoScroll && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [filteredLogs, autoScroll]); - if (autoScroll && scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [filteredLogs, autoScroll]); + return ( +
+
+
+
+
+ - return ( -
-
-
-
-
- + - + - + +
- -
- - -
-
- {filteredLogs.length > 0 ? ( - filteredLogs.map((filteredLog: LogLine, index: number) => ( - - )) - ) : isLoading ? ( -
- -
- ) : ( -
- No logs found -
- )} -
-
-
-
- ); -}; \ No newline at end of file + +
+
+ {filteredLogs.length > 0 ? ( + filteredLogs.map((filteredLog: LogLine, index: number) => ( + + )) + ) : isLoading ? ( +
+ +
+ ) : ( +
+ No logs found +
+ )} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/docker/logs/line-count-filter.tsx b/apps/dokploy/components/dashboard/docker/logs/line-count-filter.tsx new file mode 100644 index 00000000..dd7b63af --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/logs/line-count-filter.tsx @@ -0,0 +1,173 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; +import { Command as CommandPrimitive } from "cmdk"; +import { debounce } from "lodash"; +import { CheckIcon, Hash } from "lucide-react"; +import React, { useCallback, useRef } from "react"; + +const lineCountOptions = [ + { label: "100 lines", value: 100 }, + { label: "300 lines", value: 300 }, + { label: "500 lines", value: 500 }, + { label: "1000 lines", value: 1000 }, + { label: "5000 lines", value: 5000 }, +] as const; + +interface LineCountFilterProps { + value: number; + onValueChange: (value: number) => void; + title?: string; +} + +export function LineCountFilter({ + value, + onValueChange, + title = "Limit to", +}: LineCountFilterProps) { + const [open, setOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(""); + const pendingValueRef = useRef(null); + + const isPresetValue = lineCountOptions.some( + (option) => option.value === value, + ); + + const debouncedValueChange = useCallback( + debounce((numValue: number) => { + if (numValue > 0 && numValue !== value) { + onValueChange(numValue); + pendingValueRef.current = null; + } + }, 500), + [onValueChange, value], + ); + + const handleInputChange = (input: string) => { + setInputValue(input); + + // Extract numbers from input and convert + const numValue = Number.parseInt(input.replace(/[^0-9]/g, "")); + if (!Number.isNaN(numValue)) { + pendingValueRef.current = numValue; + debouncedValueChange(numValue); + } + }; + + const handleSelect = (selectedValue: string) => { + const preset = lineCountOptions.find((opt) => opt.label === selectedValue); + if (preset) { + if (preset.value !== value) { + onValueChange(preset.value); + } + setInputValue(""); + setOpen(false); + return; + } + + const numValue = Number.parseInt(selectedValue); + if ( + !Number.isNaN(numValue) && + numValue > 0 && + numValue !== value && + numValue !== pendingValueRef.current + ) { + onValueChange(numValue); + setInputValue(""); + setOpen(false); + } + }; + + React.useEffect(() => { + return () => { + debouncedValueChange.cancel(); + }; + }, [debouncedValueChange]); + + const displayValue = isPresetValue + ? lineCountOptions.find((option) => option.value === value)?.label + : `${value} lines`; + + return ( + + + + + + +
+ + { + if (e.key === "Enter") { + e.preventDefault(); + const numValue = Number.parseInt( + inputValue.replace(/[^0-9]/g, ""), + ); + if ( + !Number.isNaN(numValue) && + numValue > 0 && + numValue !== value && + numValue !== pendingValueRef.current + ) { + handleSelect(inputValue); + } + } + }} + /> +
+ + + {lineCountOptions.map((option) => { + const isSelected = value === option.value; + return ( + handleSelect(option.label)} + className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 aria-selected:bg-accent aria-selected:text-accent-foreground" + > +
+ +
+ {option.label} +
+ ); + })} +
+
+
+
+
+ ); +} + +export default LineCountFilter; diff --git a/apps/dokploy/components/dashboard/docker/logs/since-logs-filter.tsx b/apps/dokploy/components/dashboard/docker/logs/since-logs-filter.tsx new file mode 100644 index 00000000..b7caafe7 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/logs/since-logs-filter.tsx @@ -0,0 +1,125 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandGroup, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { CheckIcon } from "lucide-react"; +import React from "react"; + +export type TimeFilter = "all" | "1h" | "6h" | "24h" | "168h" | "720h"; + +const timeRanges: Array<{ label: string; value: TimeFilter }> = [ + { + label: "All time", + value: "all", + }, + { + label: "Last hour", + value: "1h", + }, + { + label: "Last 6 hours", + value: "6h", + }, + { + label: "Last 24 hours", + value: "24h", + }, + { + label: "Last 7 days", + value: "168h", + }, + { + label: "Last 30 days", + value: "720h", + }, +] as const; + +interface SinceLogsFilterProps { + value: TimeFilter; + onValueChange: (value: TimeFilter) => void; + showTimestamp: boolean; + onTimestampChange: (show: boolean) => void; + title?: string; +} + +export function SinceLogsFilter({ + value, + onValueChange, + showTimestamp, + onTimestampChange, + title = "Time range", +}: SinceLogsFilterProps) { + const selectedLabel = + timeRanges.find((range) => range.value === value)?.label ?? + "Select time range"; + + return ( + + + + + + + + + {timeRanges.map((range) => { + const isSelected = value === range.value; + return ( + { + if (!isSelected) { + onValueChange(range.value); + } + }} + > +
+ +
+ {range.label} +
+ ); + })} +
+
+
+ +
+ Show timestamps + +
+
+
+ ); +} diff --git a/apps/dokploy/components/dashboard/docker/logs/status-logs-filter.tsx b/apps/dokploy/components/dashboard/docker/logs/status-logs-filter.tsx new file mode 100644 index 00000000..3ef11517 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/logs/status-logs-filter.tsx @@ -0,0 +1,170 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandGroup, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; +import { CheckIcon } from "lucide-react"; +import type React from "react"; + +interface StatusLogsFilterProps { + value?: string[]; + setValue?: (value: string[]) => void; + title?: string; + options: { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; + }[]; +} + +export function StatusLogsFilter({ + value = [], + setValue, + title, + options, +}: StatusLogsFilterProps) { + const selectedValues = new Set(value as string[]); + const allSelected = selectedValues.size === 0; + + const getSelectedBadges = () => { + if (allSelected) { + return ( + + All + + ); + } + + if (selectedValues.size >= 1) { + const selected = options.find((opt) => selectedValues.has(opt.value)); + return ( + <> + + {selected?.label} + + {selectedValues.size > 1 && ( + + +{selectedValues.size - 1} + + )} + + ); + } + + return null; + }; + + return ( + + + + + + + + + { + setValue?.([]); // Empty array means "All" + }} + > +
+ +
+ All +
+ {options.map((option) => { + const isSelected = selectedValues.has(option.value); + return ( + { + const newValues = new Set(selectedValues); + if (isSelected) { + newValues.delete(option.value); + } else { + newValues.add(option.value); + } + setValue?.(Array.from(newValues)); + }} + > +
+ +
+ {option.icon && ( + + )} + + {option.label} + +
+ ); + })} +
+
+
+
+
+ ); +} diff --git a/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx b/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx index cdbbb2c8..c25acc67 100644 --- a/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx @@ -7,6 +7,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; +import { FancyAnsi } from "fancy-ansi"; import { escapeRegExp } from "lodash"; import React from "react"; import { type LogLine, getLogType } from "./utils"; @@ -17,6 +18,8 @@ interface LogLineProps { searchTerm?: string; } +const fancyAnsi = new FancyAnsi(); + export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) { const { timestamp, message, rawTimestamp } = log; const { type, variant, color } = getLogType(message); @@ -33,17 +36,42 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) { : "--- No time found ---"; const highlightMessage = (text: string, term: string) => { - if (!term) return text; + if (!term) { + return ( + + ); + } - const parts = text.split(new RegExp(`(${escapeRegExp(term)})`, "gi")); - return parts.map((part, index) => - part.toLowerCase() === term.toLowerCase() ? ( - - {part} - - ) : ( - part - ), + const htmlContent = fancyAnsi.toHtml(text); + const modifiedContent = htmlContent.replace( + /]*)>([^<]*)<\/span>/g, + (match, attrs, content) => { + const searchRegex = new RegExp(`(${escapeRegExp(term)})`, "gi"); + if (!content.match(searchRegex)) return match; + + const segments = content.split(searchRegex); + const wrappedSegments = segments + .map((segment: string) => + segment.toLowerCase() === term.toLowerCase() + ? `${segment}` + : segment, + ) + .join(""); + + return `${wrappedSegments}`; + }, + ); + + return ( + ); }; @@ -104,7 +132,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
- {searchTerm ? highlightMessage(message, searchTerm) : message} + {highlightMessage(message, searchTerm || "")}
); diff --git a/apps/dokploy/components/dashboard/docker/logs/utils.ts b/apps/dokploy/components/dashboard/docker/logs/utils.ts index 409c6989..cf0b30bb 100644 --- a/apps/dokploy/components/dashboard/docker/logs/utils.ts +++ b/apps/dokploy/components/dashboard/docker/logs/utils.ts @@ -1,5 +1,5 @@ -export type LogType = "error" | "warning" | "success" | "info" | "debug"; -export type LogVariant = "red" | "yellow" | "green" | "blue" | "orange"; +export type LogType = "error" | "warning" | "success" | "info" | "debug"; +export type LogVariant = "red" | "yellow" | "green" | "blue" | "orange"; export interface LogLine { rawTimestamp: string | null; @@ -138,8 +138,12 @@ export const getLogType = (message: string): LogStyle => { if ( /(?:^|\s)(?:info|inf):?\s/i.test(lowerMessage) || - /\[(info|log|debug|trace|server|db|api|http|request|response)\]/i.test(lowerMessage) || - /\b(?:version|config|import|load|get|HTTP|PATCH|POST|debug)\b:?/i.test(lowerMessage) + /\[(info|log|debug|trace|server|db|api|http|request|response)\]/i.test( + lowerMessage, + ) || + /\b(?:version|config|import|load|get|HTTP|PATCH|POST|debug)\b:?/i.test( + lowerMessage, + ) ) { return LOG_STYLES.debug; } diff --git a/apps/dokploy/components/dashboard/docker/terminal/docker-terminal-modal.tsx b/apps/dokploy/components/dashboard/docker/terminal/docker-terminal-modal.tsx index c3dba4f5..e7301b0a 100644 --- a/apps/dokploy/components/dashboard/docker/terminal/docker-terminal-modal.tsx +++ b/apps/dokploy/components/dashboard/docker/terminal/docker-terminal-modal.tsx @@ -59,7 +59,10 @@ export const DockerTerminalModal = ({ {children} - + event.preventDefault()} + > Docker Terminal @@ -73,7 +76,7 @@ export const DockerTerminalModal = ({ serverId={serverId || ""} /> - + event.preventDefault()}> Are you sure you want to close the terminal? diff --git a/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx b/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx index 42683887..bf14680a 100644 --- a/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx +++ b/apps/dokploy/components/dashboard/docker/terminal/docker-terminal.tsx @@ -4,6 +4,7 @@ import { FitAddon } from "xterm-addon-fit"; import "@xterm/xterm/css/xterm.css"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { AttachAddon } from "@xterm/addon-attach"; +import { useTheme } from "next-themes"; interface Props { id: string; @@ -18,6 +19,7 @@ export const DockerTerminal: React.FC = ({ }) => { const termRef = useRef(null); const [activeWay, setActiveWay] = React.useState("bash"); + const { resolvedTheme } = useTheme(); useEffect(() => { const container = document.getElementById(id); if (container) { @@ -28,8 +30,9 @@ export const DockerTerminal: React.FC = ({ lineHeight: 1.4, convertEol: true, theme: { - cursor: "transparent", + cursor: resolvedTheme === "light" ? "#000000" : "transparent", background: "rgba(0, 0, 0, 0)", + foreground: "currentColor", }, }); const addonFit = new FitAddon(); diff --git a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx index 69b1a332..ccc16f3e 100644 --- a/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx +++ b/apps/dokploy/components/dashboard/settings/certificates/show-certificates.tsx @@ -6,13 +6,144 @@ import { CardTitle, } from "@/components/ui/card"; import { api } from "@/utils/api"; -import { ShieldCheck } from "lucide-react"; +import { AlertCircle, Link, ShieldCheck } from "lucide-react"; import { AddCertificate } from "./add-certificate"; import { DeleteCertificate } from "./delete-certificate"; export const ShowCertificates = () => { const { data } = api.certificates.all.useQuery(); + const extractExpirationDate = (certData: string): Date | null => { + try { + const match = certData.match( + /-----BEGIN CERTIFICATE-----\s*([^-]+)\s*-----END CERTIFICATE-----/, + ); + if (!match?.[1]) return null; + + const base64Cert = match[1].replace(/\s/g, ""); + const binaryStr = window.atob(base64Cert); + const bytes = new Uint8Array(binaryStr.length); + + for (let i = 0; i < binaryStr.length; i++) { + bytes[i] = binaryStr.charCodeAt(i); + } + + let dateFound = 0; + for (let i = 0; i < bytes.length - 2; i++) { + if (bytes[i] === 0x17 || bytes[i] === 0x18) { + const dateType = bytes[i]; + const dateLength = bytes[i + 1]; + if (typeof dateLength === "undefined") continue; + + if (dateFound === 0) { + dateFound++; + i += dateLength + 1; + continue; + } + + let dateStr = ""; + for (let j = 0; j < dateLength; j++) { + const charCode = bytes[i + 2 + j]; + if (typeof charCode === "undefined") continue; + dateStr += String.fromCharCode(charCode); + } + + if (dateType === 0x17) { + // UTCTime (YYMMDDhhmmssZ) + const year = Number.parseInt(dateStr.slice(0, 2)); + const fullYear = year >= 50 ? 1900 + year : 2000 + year; + return new Date( + Date.UTC( + fullYear, + Number.parseInt(dateStr.slice(2, 4)) - 1, + Number.parseInt(dateStr.slice(4, 6)), + Number.parseInt(dateStr.slice(6, 8)), + Number.parseInt(dateStr.slice(8, 10)), + Number.parseInt(dateStr.slice(10, 12)), + ), + ); + } + + // GeneralizedTime (YYYYMMDDhhmmssZ) + return new Date( + Date.UTC( + Number.parseInt(dateStr.slice(0, 4)), + Number.parseInt(dateStr.slice(4, 6)) - 1, + Number.parseInt(dateStr.slice(6, 8)), + Number.parseInt(dateStr.slice(8, 10)), + Number.parseInt(dateStr.slice(10, 12)), + Number.parseInt(dateStr.slice(12, 14)), + ), + ); + } + } + return null; + } catch (error) { + console.error("Error parsing certificate:", error); + return null; + } + }; + + const getExpirationStatus = (certData: string) => { + const expirationDate = extractExpirationDate(certData); + + if (!expirationDate) + return { + status: "unknown" as const, + className: "text-muted-foreground", + message: "Could not determine expiration", + }; + + const now = new Date(); + const daysUntilExpiration = Math.ceil( + (expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), + ); + + if (daysUntilExpiration < 0) { + return { + status: "expired" as const, + className: "text-red-500", + message: `Expired on ${expirationDate.toLocaleDateString([], { + year: "numeric", + month: "long", + day: "numeric", + })}`, + }; + } + + if (daysUntilExpiration <= 30) { + return { + status: "warning" as const, + className: "text-yellow-500", + message: `Expires in ${daysUntilExpiration} days`, + }; + } + + return { + status: "valid" as const, + className: "text-muted-foreground", + message: `Expires ${expirationDate.toLocaleDateString([], { + year: "numeric", + month: "long", + day: "numeric", + })}`, + }; + }; + + const getCertificateChainInfo = (certData: string) => { + const certCount = (certData.match(/-----BEGIN CERTIFICATE-----/g) || []) + .length; + return certCount > 1 + ? { + isChain: true, + count: certCount, + } + : { + isChain: false, + count: 1, + }; + }; + return (
@@ -23,7 +154,7 @@ export const ShowCertificates = () => { - {data?.length === 0 ? ( + {!data?.length ? (
@@ -35,21 +166,53 @@ export const ShowCertificates = () => { ) : (
- {data?.map((destination, index) => ( -
- - {index + 1}. {destination.name} - -
- + {data.map((certificate, index) => { + const expiration = getExpirationStatus( + certificate.certificateData, + ); + const chainInfo = getCertificateChainInfo( + certificate.certificateData, + ); + return ( +
+
+
+ + {index + 1}. {certificate.name} + + {chainInfo.isChain && ( +
+ + + Chain ({chainInfo.count}) + +
+ )} +
+ +
+
+ {expiration.status !== "valid" && ( + + )} + {expiration.message} + {certificate.autoRenew && + expiration.status !== "valid" && ( + + (Auto-renewal enabled) + + )} +
-
- ))} + ); + })}
diff --git a/apps/dokploy/components/dashboard/settings/cluster/nodes/show-node-data.tsx b/apps/dokploy/components/dashboard/settings/cluster/nodes/show-node-data.tsx index c597b948..e2adbed7 100644 --- a/apps/dokploy/components/dashboard/settings/cluster/nodes/show-node-data.tsx +++ b/apps/dokploy/components/dashboard/settings/cluster/nodes/show-node-data.tsx @@ -1,3 +1,4 @@ +import { CodeEditor } from "@/components/shared/code-editor"; import { Dialog, DialogContent, @@ -33,7 +34,13 @@ export const ShowNodeData = ({ data }: Props) => {
-							{JSON.stringify(data, null, 2)}
+							
 						
diff --git a/apps/dokploy/components/dashboard/settings/notifications/delete-notification.tsx b/apps/dokploy/components/dashboard/settings/notifications/delete-notification.tsx index 468db851..4bb197b2 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/delete-notification.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/delete-notification.tsx @@ -11,7 +11,7 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { api } from "@/utils/api"; -import { TrashIcon } from "lucide-react"; +import { Trash2 } from "lucide-react"; import React from "react"; import { toast } from "sonner"; @@ -24,8 +24,13 @@ export const DeleteNotification = ({ notificationId }: Props) => { return ( - diff --git a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx index c22f7b72..2b5d02e0 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/show-notifications.tsx @@ -40,48 +40,58 @@ export const ShowNotifications = () => {
) : (
-
- {data?.map((notification, index) => ( -
-
- {notification.notificationType === "slack" && ( - - )} - {notification.notificationType === "telegram" && ( - - )} - {notification.notificationType === "discord" && ( - - )} - {notification.notificationType === "email" && ( - - )} - - {notification.name} - -
- -
- - -
-
- ))} -
-
- +
+ {data?.map((notification, index) => ( +
+
+ {notification.notificationType === "slack" && ( +
+ +
+ )} + {notification.notificationType === "telegram" && ( +
+ +
+ )} + {notification.notificationType === "discord" && ( +
+ +
+ )} + {notification.notificationType === "email" && ( +
+ +
+ )} +
+ + {notification.name} + + + {notification.notificationType?.[0]?.toUpperCase() + notification.notificationType?.slice(1)} notification + +
+
+
+ + +
+ ))}
+ +
+ +
+
)} diff --git a/apps/dokploy/components/dashboard/settings/notifications/update-notification.tsx b/apps/dokploy/components/dashboard/settings/notifications/update-notification.tsx index 9bdf35f1..5c594dc4 100644 --- a/apps/dokploy/components/dashboard/settings/notifications/update-notification.tsx +++ b/apps/dokploy/components/dashboard/settings/notifications/update-notification.tsx @@ -26,7 +26,7 @@ import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { api } from "@/utils/api"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Mail, PenBoxIcon } from "lucide-react"; +import { Mail, Pen } from "lucide-react"; import { useEffect, useState } from "react"; import { FieldErrors, useFieldArray, useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -218,8 +218,10 @@ export const UpdateNotification = ({ notificationId }: Props) => { return ( - diff --git a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx index 65ccff0e..1141397f 100644 --- a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx @@ -104,6 +104,7 @@ export const ProfileForm = () => { .then(async () => { await refetch(); toast.success("Profile Updated"); + form.reset(); }) .catch(() => { toast.error("Error to Update the profile"); diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx index 4385dc6a..546069c5 100644 --- a/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/actions/show-traefik-actions.tsx @@ -26,6 +26,7 @@ import { cn } from "@/lib/utils"; import { useTranslation } from "next-i18next"; import { EditTraefikEnv } from "../../web-server/edit-traefik-env"; import { ShowModalLogs } from "../../web-server/show-modal-logs"; +import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports"; interface Props { serverId?: string; @@ -128,6 +129,14 @@ export const ShowTraefikActions = ({ serverId }: Props) => { Enter the terminal */} + + e.preventDefault()} + className="cursor-pointer" + > + {t("settings.server.webServer.traefik.managePorts")} + + diff --git a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx index a174cd9c..d476fb15 100644 --- a/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/show-servers.tsx @@ -33,6 +33,7 @@ import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal"; import { UpdateServer } from "./update-server"; import { useRouter } from "next/router"; import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription"; +import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal"; export const ShowServers = () => { const router = useRouter(); @@ -259,6 +260,9 @@ export const ShowServers = () => { + )} diff --git a/apps/dokploy/components/dashboard/settings/servers/show-swarm-overview-modal.tsx b/apps/dokploy/components/dashboard/settings/servers/show-swarm-overview-modal.tsx new file mode 100644 index 00000000..f8acd207 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/servers/show-swarm-overview-modal.tsx @@ -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 ( + + + e.preventDefault()} + > + Show Swarm Overview + + + + +
+ + + Swarm Overview + +

+ See all details of your swarm node +

+
+
+ +
+
+ +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/web-server/docker-terminal-modal.tsx b/apps/dokploy/components/dashboard/settings/web-server/docker-terminal-modal.tsx index f81be0ad..f5b6b2a9 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/docker-terminal-modal.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/docker-terminal-modal.tsx @@ -80,7 +80,10 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => { return ( {children} - + event.preventDefault()} + > Docker Terminal @@ -119,7 +122,7 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => { containerId={containerId || "select-a-container"} /> - + event.preventDefault()}> Are you sure you want to close the terminal? diff --git a/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx b/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx new file mode 100644 index 00000000..965948ca --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx @@ -0,0 +1,230 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import { api } from "@/utils/api"; +import { useTranslation } from "next-i18next"; +import type React from "react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +/** + * 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 { + children: React.ReactNode; + serverId?: string; +} + +/** + * Represents a port mapping configuration for Traefik + * @interface AdditionalPort + * @property {number} targetPort - The internal port that the service is listening on + * @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 + * - "ingress": Publishes the port through the Swarm routing mesh + */ +interface AdditionalPort { + targetPort: number; + 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 + * + * + * + * ``` + */ +export const ManageTraefikPorts = ({ children, serverId }: Props) => { + const { t } = useTranslation("settings"); + const [open, setOpen] = useState(false); + const [additionalPorts, setAdditionalPorts] = useState([]); + + const { data: currentPorts, refetch: refetchPorts } = + api.settings.getTraefikPorts.useQuery({ + serverId, + }); + + const { mutateAsync: updatePorts, isLoading } = + api.settings.updateTraefikPorts.useMutation({ + onSuccess: () => { + refetchPorts(); + }, + }); + + useEffect(() => { + if (currentPorts) { + setAdditionalPorts(currentPorts); + } + }, [currentPorts]); + + const handleAddPort = () => { + setAdditionalPorts([ + ...additionalPorts, + { targetPort: 0, publishedPort: 0, publishMode: "host" }, + ]); + }; + + const handleUpdatePorts = async () => { + try { + await updatePorts({ + serverId, + additionalPorts, + }); + toast.success(t("settings.server.webServer.traefik.portsUpdated")); + setOpen(false); + } catch (error) { + toast.error(t("settings.server.webServer.traefik.portsUpdateError")); + } + }; + + return ( + <> +
setOpen(true)}>{children}
+ + + + + {t("settings.server.webServer.traefik.managePorts")} + + + {t("settings.server.webServer.traefik.managePortsDescription")} + + +
+ {additionalPorts.map((port, index) => ( +
+
+ + { + const newPorts = [...additionalPorts]; + + if (newPorts[index]) { + newPorts[index].targetPort = Number.parseInt( + e.target.value, + ); + } + + setAdditionalPorts(newPorts); + }} + className="w-full rounded border p-2" + /> +
+
+ + { + const newPorts = [...additionalPorts]; + if (newPorts[index]) { + newPorts[index].publishedPort = Number.parseInt( + e.target.value, + ); + } + setAdditionalPorts(newPorts); + }} + className="w-full rounded border p-2" + /> +
+
+ + +
+
+ +
+
+ ))} +
+ + +
+
+
+
+ + ); +}; diff --git a/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx b/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx index 366784fc..d38f5d9e 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/terminal.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef } from "react"; import { FitAddon } from "xterm-addon-fit"; import "@xterm/xterm/css/xterm.css"; import { AttachAddon } from "@xterm/addon-attach"; +import { useTheme } from "next-themes"; interface Props { id: string; @@ -12,7 +13,7 @@ interface Props { export const Terminal: React.FC = ({ id, serverId }) => { const termRef = useRef(null); - + const { resolvedTheme } = useTheme(); useEffect(() => { const container = document.getElementById(id); if (container) { @@ -23,8 +24,9 @@ export const Terminal: React.FC = ({ id, serverId }) => { lineHeight: 1.4, convertEol: true, theme: { - cursor: "transparent", - background: "transparent", + cursor: resolvedTheme === "light" ? "#000000" : "transparent", + background: "rgba(0, 0, 0, 0)", + foreground: "currentColor", }, }); const addonFit = new FitAddon(); diff --git a/apps/dokploy/components/dashboard/settings/web-server/toggle-auto-check-updates.tsx b/apps/dokploy/components/dashboard/settings/web-server/toggle-auto-check-updates.tsx new file mode 100644 index 00000000..fb3776b1 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/web-server/toggle-auto-check-updates.tsx @@ -0,0 +1,28 @@ +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { useState } from "react"; + +export const ToggleAutoCheckUpdates = ({ disabled }: { disabled: boolean }) => { + const [enabled, setEnabled] = useState( + localStorage.getItem("enableAutoCheckUpdates") === "true", + ); + + const handleToggle = (checked: boolean) => { + setEnabled(checked); + localStorage.setItem("enableAutoCheckUpdates", String(checked)); + }; + + return ( +
+ + +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx b/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx index 48a61c7a..6c8475d3 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx @@ -3,91 +3,224 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, - DialogDescription, - DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { api } from "@/utils/api"; -import { RefreshCcw } from "lucide-react"; +import { + Bug, + Download, + Info, + RefreshCcw, + Server, + ServerCrash, + Sparkles, + Stars, +} from "lucide-react"; import Link from "next/link"; import { useState } from "react"; import { toast } from "sonner"; +import { ToggleAutoCheckUpdates } from "./toggle-auto-check-updates"; import { UpdateWebServer } from "./update-webserver"; export const UpdateServer = () => { - const [isUpdateAvailable, setIsUpdateAvailable] = useState( - null, - ); - const { mutateAsync: checkAndUpdateImage, isLoading } = - api.settings.checkAndUpdateImage.useMutation(); + const [hasCheckedUpdate, setHasCheckedUpdate] = useState(false); + const [isUpdateAvailable, setIsUpdateAvailable] = useState(false); + const { mutateAsync: getUpdateData, isLoading } = + api.settings.getUpdateData.useMutation(); + const { data: dokployVersion } = api.settings.getDokployVersion.useQuery(); + const { data: releaseTag } = api.settings.getReleaseTag.useQuery(); const [isOpen, setIsOpen] = useState(false); + const [latestVersion, setLatestVersion] = useState(""); + + const handleCheckUpdates = async () => { + try { + const updateData = await getUpdateData(); + const versionToUpdate = updateData.latestVersion || ""; + setHasCheckedUpdate(true); + setIsUpdateAvailable(updateData.updateAvailable); + setLatestVersion(versionToUpdate); + + if (updateData.updateAvailable) { + toast.success(versionToUpdate, { + description: "New version available!", + }); + } else { + toast.info("No updates available"); + } + } catch (error) { + console.error("Error checking for updates:", error); + setHasCheckedUpdate(true); + setIsUpdateAvailable(false); + toast.error( + "An error occurred while checking for updates, please try again.", + ); + } + }; return ( - - - - Web Server Update - - Check new releases and update your dokploy - - + +
+ + Web Server Update + + {dokployVersion && ( +
+ + + {dokployVersion} | {releaseTag} + +
+ )} +
-
- - We suggest to update your dokploy to the latest version only if you: - -
    -
  • Want to try the latest features
  • -
  • Some bug that is blocking to use some features
  • -
- - We recommend checking the latest version for any breaking changes - before updating. Go to{" "} - - Dokploy Releases - {" "} - to check the latest version. - + {/* Initial state */} + {!hasCheckedUpdate && ( +
+

+ Check for new releases and update Dokploy. +
+
+ We recommend checking for updates regularly to ensure you have the + latest features and security improvements. +

+
+ )} -
- {isUpdateAvailable === false && ( -
- - - You are using the latest version + {/* Update available state */} + {isUpdateAvailable && latestVersion && ( +
+
+
+ + + + + + + New version available:
- )} + + {latestVersion} + +
+ +
+

+ A new version of the server software is available. Consider + updating if you: +

+
    +
  • + + + Want to access the latest features and improvements + +
  • +
  • + + + Are experiencing issues that may be resolved in the new + version + +
  • +
+
+
+ )} + + {/* Up to date state */} + {hasCheckedUpdate && !isUpdateAvailable && !isLoading && ( +
+
+
+ +
+
+

+ You are using the latest version +

+

+ Your server is up to date with all the latest features and + security improvements. +

+
+
+
+ )} + + {hasCheckedUpdate && isLoading && ( +
+
+
+ +
+
+

Checking for updates...

+

+ Please wait while we pull the latest version information from + Docker Hub. +

+
+
+
+ )} + + {isUpdateAvailable && ( +
+
+ +
+ We recommend reviewing the{" "} + + release notes + {" "} + for any breaking changes before updating. +
+
+
+ )} + +
+ +
+ +
+
+ {isUpdateAvailable ? ( ) : ( )}
@@ -96,3 +229,5 @@ export const UpdateServer = () => {
); }; + +export default UpdateServer; diff --git a/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx b/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx index 47d38310..c1e5de70 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx @@ -11,24 +11,53 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { api } from "@/utils/api"; +import { HardDriveDownload } from "lucide-react"; import { toast } from "sonner"; -export const UpdateWebServer = () => { +interface Props { + isNavbar?: boolean; +} + +export const UpdateWebServer = ({ isNavbar }: Props) => { const { mutateAsync: updateServer, isLoading } = api.settings.updateServer.useMutation(); + + const buttonLabel = isNavbar ? "Update available" : "Update Server"; + + const handleConfirm = async () => { + try { + await updateServer(); + toast.success( + "The server has been updated. The page will be reloaded to reflect the changes...", + ); + setTimeout(() => { + // Allow seeing the toast before reloading + window.location.reload(); + }, 2000); + } catch (error) { + console.error("Error updating server:", error); + toast.error( + "An error occurred while updating the server, please try again.", + ); + } + }; + return ( @@ -36,19 +65,12 @@ export const UpdateWebServer = () => { Are you absolutely sure? This action cannot be undone. This will update the web server to the - new version. + new version. The page will be reloaded once the update is finished. Cancel - { - await updateServer(); - toast.success("Please reload the browser to see the changes"); - }} - > - Confirm - + Confirm diff --git a/apps/dokploy/components/dashboard/swarm/applications/columns.tsx b/apps/dokploy/components/dashboard/swarm/applications/columns.tsx new file mode 100644 index 00000000..1961cd99 --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/applications/columns.tsx @@ -0,0 +1,218 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import { ArrowUpDown, MoreHorizontal } from "lucide-react"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +import { Badge } from "@/components/ui/badge"; +import { ShowNodeConfig } from "../details/show-node-config"; +// import { ShowContainerConfig } from "../config/show-container-config"; +// import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs"; +// import { DockerTerminalModal } from "../terminal/docker-terminal-modal"; +// import type { Container } from "./show-containers"; + +export interface ApplicationList { + ID: string; + Image: string; + Mode: string; + Name: string; + Ports: string; + Replicas: string; + CurrentState: string; + DesiredState: string; + Error: string; + Node: string; +} + +export const columns: ColumnDef[] = [ + { + accessorKey: "ID", + accessorFn: (row) => row.ID, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return
{row.getValue("ID")}
; + }, + }, + { + accessorKey: "Name", + accessorFn: (row) => row.Name, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return
{row.getValue("Name")}
; + }, + }, + { + accessorKey: "Image", + accessorFn: (row) => row.Image, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return
{row.getValue("Image")}
; + }, + }, + { + accessorKey: "Mode", + accessorFn: (row) => row.Mode, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return
{row.getValue("Mode")}
; + }, + }, + { + accessorKey: "CurrentState", + accessorFn: (row) => row.CurrentState, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const value = row.getValue("CurrentState") as string; + const valueStart = value.startsWith("Running") + ? "Running" + : value.startsWith("Shutdown") + ? "Shutdown" + : value; + return ( +
+ + {value} + +
+ ); + }, + }, + { + accessorKey: "DesiredState", + accessorFn: (row) => row.DesiredState, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return
{row.getValue("DesiredState")}
; + }, + }, + + { + accessorKey: "Replicas", + accessorFn: (row) => row.Replicas, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return
{row.getValue("Replicas")}
; + }, + }, + + { + accessorKey: "Ports", + accessorFn: (row) => row.Ports, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return
{row.getValue("Ports")}
; + }, + }, + { + accessorKey: "Errors", + accessorFn: (row) => row.Error, + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return
{row.getValue("Errors")}
; + }, + }, +]; diff --git a/apps/dokploy/components/dashboard/swarm/applications/data-table.tsx b/apps/dokploy/components/dashboard/swarm/applications/data-table.tsx new file mode 100644 index 00000000..03915c19 --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/applications/data-table.tsx @@ -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 { + columns: ColumnDef[]; + data: TData[]; +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const [sorting, setSorting] = React.useState([]); + const [columnFilters, setColumnFilters] = React.useState( + [], + ); + const [columnVisibility, setColumnVisibility] = + React.useState({}); + 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 ( +
+
+
+ + table.getColumn("Name")?.setFilterValue(event.target.value) + } + className="md:max-w-sm" + /> + + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ); + })} + + +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table?.getRowModel()?.rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results. + {/* {isLoading ? ( +
+ + Loading... + +
+ ) : ( + <>No results. + )} */} +
+
+ )} +
+
+ + {data && data?.length > 0 && ( +
+
+ + +
+
+ )} +
+
+ ); +} diff --git a/apps/dokploy/components/dashboard/swarm/applications/show-applications.tsx b/apps/dokploy/components/dashboard/swarm/applications/show-applications.tsx new file mode 100644 index 00000000..132cb008 --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/applications/show-applications.tsx @@ -0,0 +1,116 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { api } from "@/utils/api"; +import { Layers, Loader2 } from "lucide-react"; +import React from "react"; +import { columns } from "./columns"; +import { DataTable } from "./data-table"; + +interface Props { + serverId?: string; +} + +interface ApplicationList { + ID: string; + Image: string; + Mode: string; + Name: string; + Ports: string; + Replicas: string; + CurrentState: string; + DesiredState: string; + Error: string; + Node: string; +} + +export const ShowNodeApplications = ({ serverId }: Props) => { + const { data: NodeApps, isLoading: NodeAppsLoading } = + api.swarm.getNodeApps.useQuery({ serverId }); + + let applicationList = ""; + + if (NodeApps && NodeApps.length > 0) { + applicationList = NodeApps.map((app) => app.Name).join(" "); + } + + const { data: NodeAppDetails, isLoading: NodeAppDetailsLoading } = + api.swarm.getAppInfos.useQuery({ appName: applicationList, serverId }); + + if (NodeAppsLoading || NodeAppDetailsLoading) { + return ( + + + + + + ); + } + + if (!NodeApps || !NodeAppDetails) { + return ( + + No data found + + ); + } + + const combinedData: ApplicationList[] = NodeApps.flatMap((app) => { + const appDetails = + NodeAppDetails?.filter((detail) => + detail.Name.startsWith(`${app.Name}.`), + ) || []; + + if (appDetails.length === 0) { + return [ + { + ...app, + CurrentState: "N/A", + DesiredState: "N/A", + Error: "", + Node: "N/A", + Ports: app.Ports, + }, + ]; + } + + return appDetails.map((detail) => ({ + ...app, + CurrentState: detail.CurrentState, + DesiredState: detail.DesiredState, + Error: detail.Error, + Node: detail.Node, + Ports: detail.Ports || app.Ports, + })); + }); + + return ( + + + + + + + Node Applications + + See in detail the applications running on this node + + +
+ +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/swarm/details/details-card.tsx b/apps/dokploy/components/dashboard/swarm/details/details-card.tsx new file mode 100644 index 00000000..a499f898 --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/details/details-card.tsx @@ -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 ; + case "Down": + return ; + default: + return ; + } + }; + + if (isLoading) { + return ( + + + + + {getStatusIcon(node.Status)} + {node.Hostname} + + + {node.ManagerStatus || "Worker"} + + + + +
+ +
+
+
+ ); + } + + return ( + + + + + {getStatusIcon(node.Status)} + {node.Hostname} + + + {node.ManagerStatus || "Worker"} + + + + +
+
+ Status: + {node.Status} +
+
+ IP Address: + {isLoading ? ( + + ) : ( + {data?.Status?.Addr} + )} +
+
+ Availability: + {node.Availability} +
+
+ Engine Version: + {node.EngineVersion} +
+
+ CPU: + {isLoading ? ( + + ) : ( + + {(data?.Description?.Resources?.NanoCPUs / 1e9).toFixed(2)} GHz + + )} +
+
+ Memory: + {isLoading ? ( + + ) : ( + + {( + data?.Description?.Resources?.MemoryBytes / + 1024 ** 3 + ).toFixed(2)}{" "} + GB + + )} +
+
+ TLS Status: + {node.TLSStatus} +
+
+
+ + +
+
+
+ ); +} diff --git a/apps/dokploy/components/dashboard/swarm/details/show-node-config.tsx b/apps/dokploy/components/dashboard/swarm/details/show-node-config.tsx new file mode 100644 index 00000000..a41c5a49 --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/details/show-node-config.tsx @@ -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 ( + + + + + + + Node Config + + See in detail the metadata of this node + + +
+ +
+							{/* {JSON.stringify(data, null, 2)} */}
+							
+						
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/swarm/monitoring-card.tsx b/apps/dokploy/components/dashboard/swarm/monitoring-card.tsx new file mode 100644 index 00000000..e2453dd9 --- /dev/null +++ b/apps/dokploy/components/dashboard/swarm/monitoring-card.tsx @@ -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 ( +
+
+
+ +
+
+
+ ); + } + + if (!nodes) { + return ( +
+
+
+ Failed to load data +
+
+
+ ); + } + + 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 ; + case "Down": + return ; + case "Disconnected": + return ; + default: + return ; + } + }; + + return ( +
+
+

Docker Swarm Overview

+ {!serverId && ( + + )} +
+ + + + + Monitor + + + +
+
+ Total Nodes: + {totalNodes} +
+
+ Active Nodes: + + + + + {activeNodesCount} / {totalNodes} + + + +
+ {activeNodes.map((node) => ( +
+ {getStatusIcon(node.Status)} + {node.Hostname} +
+ ))} +
+
+
+
+
+
+ Manager Nodes: + + + + + {managerNodesCount} / {totalNodes} + + + +
+ {managerNodes.map((node) => ( +
+ {getStatusIcon(node.Status)} + {node.Hostname} +
+ ))} +
+
+
+
+
+
+
+

Node Status:

+
    + {nodes.map((node) => ( +
  • + + {getStatusIcon(node.Status)} + {node.Hostname} + + + {node.ManagerStatus || "Worker"} + +
  • + ))} +
+
+
+
+
+ {nodes.map((node) => ( + + ))} +
+
+ ); +} diff --git a/apps/dokploy/components/layouts/navbar.tsx b/apps/dokploy/components/layouts/navbar.tsx index cead4683..0e52d701 100644 --- a/apps/dokploy/components/layouts/navbar.tsx +++ b/apps/dokploy/components/layouts/navbar.tsx @@ -12,11 +12,16 @@ import { api } from "@/utils/api"; import { HeartIcon } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/router"; +import { useEffect, useRef, useState } from "react"; +import { UpdateWebServer } from "../dashboard/settings/web-server/update-webserver"; import { Logo } from "../shared/logo"; import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; import { buttonVariants } from "../ui/button"; +const AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 7; + export const Navbar = () => { + const [isUpdateAvailable, setIsUpdateAvailable] = useState(false); const router = useRouter(); const { data } = api.auth.get.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery(); @@ -29,6 +34,59 @@ export const Navbar = () => { }, ); const { mutateAsync } = api.auth.logout.useMutation(); + const { mutateAsync: getUpdateData } = + api.settings.getUpdateData.useMutation(); + + const checkUpdatesIntervalRef = useRef(null); + + useEffect(() => { + // Handling of automatic check for server updates + if (isCloud) { + return; + } + + if (!localStorage.getItem("enableAutoCheckUpdates")) { + // Enable auto update checking by default if user didn't change it + localStorage.setItem("enableAutoCheckUpdates", "true"); + } + + const clearUpdatesInterval = () => { + if (checkUpdatesIntervalRef.current) { + clearInterval(checkUpdatesIntervalRef.current); + } + }; + + const checkUpdates = async () => { + try { + if (localStorage.getItem("enableAutoCheckUpdates") !== "true") { + return; + } + + const { updateAvailable } = await getUpdateData(); + + if (updateAvailable) { + // Stop interval when update is available + clearUpdatesInterval(); + setIsUpdateAvailable(true); + } + } catch (error) { + console.error("Error auto-checking for updates:", error); + } + }; + + checkUpdatesIntervalRef.current = setInterval( + checkUpdates, + AUTO_CHECK_UPDATES_INTERVAL_MINUTES * 60000, + ); + + // Also check for updates on initial page load + checkUpdates(); + + return () => { + clearUpdatesInterval(); + }; + }, []); + return (
+ {isUpdateAvailable && ( +
+ +
+ )} { const elements: TabInfo[] = [ @@ -60,6 +61,15 @@ const getTabMaps = (isCloud: boolean) => { }, 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", description: "Manage your requests", diff --git a/apps/dokploy/components/ui/badge.tsx b/apps/dokploy/components/ui/badge.tsx index 911b0071..9c41234d 100644 --- a/apps/dokploy/components/ui/badge.tsx +++ b/apps/dokploy/components/ui/badge.tsx @@ -14,14 +14,14 @@ const badgeVariants = cva( "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", - red: "border-transparent select-none items-center whitespace-nowrap font-medium bg-red-500/15 text-destructive text-xs h-4 px-1 py-1 rounded-md", + red: "border-transparent select-none items-center whitespace-nowrap font-medium bg-red-600/20 dark:bg-red-500/15 text-destructive text-xs h-4 px-1 py-1 rounded-md", yellow: - "border-transparent select-none items-center whitespace-nowrap font-medium bg-yellow-500/15 text-yellow-500 text-xs h-4 px-1 py-1 rounded-md", + "border-transparent select-none items-center whitespace-nowrap font-medium bg-yellow-600/20 dark:bg-yellow-500/15 dark:text-yellow-500 text-yellow-600 text-xs h-4 px-1 py-1 rounded-md", orange: - "border-transparent select-none items-center whitespace-nowrap font-medium bg-orange-500/15 text-orange-500 text-xs h-4 px-1 py-1 rounded-md", + "border-transparent select-none items-center whitespace-nowrap font-medium bg-orange-600/20 dark:bg-orange-500/15 dark:text-orange-500 text-orange-600 text-xs h-4 px-1 py-1 rounded-md", green: - "border-transparent select-none items-center whitespace-nowrap font-medium bg-emerald-500/15 text-emerald-500 text-xs h-4 px-1 py-1 rounded-md", - blue: "border-transparent select-none items-center whitespace-nowrap font-medium bg-blue-500/15 text-blue-500 text-xs h-4 px-1 py-1 rounded-md", + "border-transparent select-none items-center whitespace-nowrap font-medium bg-emerald-600/20 dark:bg-emerald-500/15 dark:text-emerald-500 text-emerald-600 text-xs h-4 px-1 py-1 rounded-md", + blue: "border-transparent select-none items-center whitespace-nowrap font-medium bg-blue-600/20 dark:bg-blue-500/15 dark:text-blue-500 text-blue-600 text-xs h-4 px-1 py-1 rounded-md", blank: "border-transparent select-none items-center whitespace-nowrap font-medium dark:bg-white/15 bg-black/15 text-foreground text-xs h-4 px-1 py-1 rounded-md", outline: "text-foreground", diff --git a/apps/dokploy/components/ui/secrets.tsx b/apps/dokploy/components/ui/secrets.tsx index 5669b051..1b06b86c 100644 --- a/apps/dokploy/components/ui/secrets.tsx +++ b/apps/dokploy/components/ui/secrets.tsx @@ -62,6 +62,7 @@ export const Secrets = (props: Props) => { } language="properties" disabled={isVisible} + lineWrapping placeholder={props.placeholder} className="h-96 font-mono" {...field} diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json index 9e3eb7f7..a16dedac 100644 --- a/apps/dokploy/package.json +++ b/apps/dokploy/package.json @@ -1,6 +1,6 @@ { "name": "dokploy", - "version": "v0.15.0", + "version": "v0.15.1", "private": true, "license": "Apache-2.0", "type": "module", @@ -35,8 +35,6 @@ "test": "vitest --config __test__/vitest.config.ts" }, "dependencies": { - "react-confetti-explosion":"2.1.2", - "@stepperize/react": "4.0.1", "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-yaml": "^6.1.1", "@codemirror/language": "^6.10.1", @@ -64,6 +62,7 @@ "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-tooltip": "^1.0.7", + "@stepperize/react": "4.0.1", "@stripe/stripe-js": "4.8.0", "@tanstack/react-query": "^4.36.1", "@tanstack/react-table": "^8.16.0", @@ -87,6 +86,7 @@ "dotenv": "16.4.5", "drizzle-orm": "^0.30.8", "drizzle-zod": "0.5.1", + "fancy-ansi": "^0.1.3", "i18next": "^23.16.4", "input-otp": "^1.2.4", "js-cookie": "^3.0.5", @@ -104,6 +104,7 @@ "postgres": "3.4.4", "public-ip": "6.0.2", "react": "18.2.0", + "react-confetti-explosion": "2.1.2", "react-dom": "18.2.0", "react-hook-form": "^7.49.3", "react-i18next": "^15.1.0", diff --git a/apps/dokploy/pages/dashboard/swarm.tsx b/apps/dokploy/pages/dashboard/swarm.tsx new file mode 100644 index 00000000..15a7d793 --- /dev/null +++ b/apps/dokploy/pages/dashboard/swarm.tsx @@ -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 ( + <> +
+ +
+ + ); +}; + +export default Dashboard; + +Dashboard.getLayout = (page: ReactElement) => { + return {page}; +}; +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: {}, + }; + } +} diff --git a/apps/dokploy/public/locales/en/settings.json b/apps/dokploy/public/locales/en/settings.json index 2103ecc0..1ce54692 100644 --- a/apps/dokploy/public/locales/en/settings.json +++ b/apps/dokploy/public/locales/en/settings.json @@ -18,6 +18,14 @@ "settings.server.webServer.server.label": "Server", "settings.server.webServer.traefik.label": "Traefik", "settings.server.webServer.traefik.modifyEnv": "Modify Env", + "settings.server.webServer.traefik.managePorts": "Additional Ports", + "settings.server.webServer.traefik.managePortsDescription": "Add or remove additional ports for Traefik", + "settings.server.webServer.traefik.targetPort": "Target Port", + "settings.server.webServer.traefik.publishedPort": "Published Port", + "settings.server.webServer.traefik.addPort": "Add Port", + "settings.server.webServer.traefik.portsUpdated": "Ports updated successfully", + "settings.server.webServer.traefik.portsUpdateError": "Failed to update ports", + "settings.server.webServer.traefik.publishMode": "Publish Mode", "settings.server.webServer.storage.label": "Space", "settings.server.webServer.storage.cleanUnusedImages": "Clean unused images", "settings.server.webServer.storage.cleanUnusedVolumes": "Clean unused volumes", diff --git a/apps/dokploy/public/templates/onedev.png b/apps/dokploy/public/templates/onedev.png new file mode 100644 index 00000000..6c39e6cf Binary files /dev/null and b/apps/dokploy/public/templates/onedev.png differ diff --git a/apps/dokploy/server/api/root.ts b/apps/dokploy/server/api/root.ts index 85eb9763..68f5e4e0 100644 --- a/apps/dokploy/server/api/root.ts +++ b/apps/dokploy/server/api/root.ts @@ -21,6 +21,7 @@ import { mysqlRouter } from "./routers/mysql"; import { notificationRouter } from "./routers/notification"; import { portRouter } from "./routers/port"; import { postgresRouter } from "./routers/postgres"; +import { previewDeploymentRouter } from "./routers/preview-deployment"; import { projectRouter } from "./routers/project"; import { redirectsRouter } from "./routers/redirects"; import { redisRouter } from "./routers/redis"; @@ -30,8 +31,8 @@ import { serverRouter } from "./routers/server"; import { settingsRouter } from "./routers/settings"; import { sshRouter } from "./routers/ssh-key"; import { stripeRouter } from "./routers/stripe"; +import { swarmRouter } from "./routers/swarm"; import { userRouter } from "./routers/user"; -import { previewDeploymentRouter } from "./routers/preview-deployment"; /** * This is the primary router for your server. @@ -73,6 +74,7 @@ export const appRouter = createTRPCRouter({ github: githubRouter, server: serverRouter, stripe: stripeRouter, + swarm: swarmRouter, }); // export type definition of API diff --git a/apps/dokploy/server/api/routers/auth.ts b/apps/dokploy/server/api/routers/auth.ts index ef9db4da..a1345cca 100644 --- a/apps/dokploy/server/api/routers/auth.ts +++ b/apps/dokploy/server/api/routers/auth.ts @@ -188,9 +188,9 @@ export const authRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { const currentAuth = await findAuthByEmail(ctx.user.email); - if (input.password) { + if (input.currentPassword || input.password) { const correctPassword = bcrypt.compareSync( - input.password, + input.currentPassword || "", currentAuth?.password || "", ); if (!correctPassword) { @@ -268,7 +268,9 @@ export const authRouter = createTRPCRouter({ return auth; }), - + verifyToken: protectedProcedure.mutation(async () => { + return true; + }), one: adminProcedure.input(apiFindOneAuth).query(async ({ input }) => { const auth = await findAuthById(input.id); return auth; diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index 7c777b17..8bec9b52 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -12,6 +12,7 @@ import { } from "@/server/db/schema"; import { removeJob, schedule } from "@/server/utils/backup"; import { + DEFAULT_UPDATE_DATA, IS_CLOUD, canAccessToTraefikFiles, cleanStoppedContainers, @@ -25,6 +26,8 @@ import { findAdminById, findServerById, getDokployImage, + getDokployImageTag, + getUpdateData, initializeTraefik, logRotationManager, parseRawConfig, @@ -267,11 +270,11 @@ export const settingsRouter = createTRPCRouter({ message: "You are not authorized to access this admin", }); } - await updateAdmin(ctx.user.authId, { + const adminUpdated = await updateAdmin(ctx.user.authId, { enableDockerCleanup: input.enableDockerCleanup, }); - if (admin.enableDockerCleanup) { + if (adminUpdated?.enableDockerCleanup) { scheduleJob("docker-cleanup", "0 0 * * *", async () => { console.log( `Docker Cleanup ${new Date().toLocaleString()}] Running...`, @@ -342,17 +345,20 @@ export const settingsRouter = createTRPCRouter({ writeConfig("middlewares", input.traefikConfig); return true; }), - - checkAndUpdateImage: adminProcedure.mutation(async () => { + getUpdateData: adminProcedure.mutation(async () => { if (IS_CLOUD) { - return true; + return DEFAULT_UPDATE_DATA; } - return await pullLatestRelease(); + + return await getUpdateData(); }), updateServer: adminProcedure.mutation(async () => { if (IS_CLOUD) { return true; } + + await pullLatestRelease(); + await spawnAsync("docker", [ "service", "update", @@ -361,12 +367,16 @@ export const settingsRouter = createTRPCRouter({ getDokployImage(), "dokploy", ]); + return true; }), getDokployVersion: adminProcedure.query(() => { return packageInfo.version; }), + getReleaseTag: adminProcedure.query(() => { + return getDokployImageTag(); + }), readDirectories: protectedProcedure .input(apiServerSchema) .query(async ({ ctx, input }) => { @@ -706,6 +716,83 @@ export const settingsRouter = createTRPCRouter({ throw new Error("Failed to check GPU status"); } }), + updateTraefikPorts: adminProcedure + .input( + z.object({ + serverId: z.string().optional(), + additionalPorts: z.array( + z.object({ + targetPort: z.number(), + publishedPort: z.number(), + publishMode: z.enum(["ingress", "host"]).default("host"), + }), + ), + }), + ) + .mutation(async ({ input }) => { + try { + if (IS_CLOUD && !input.serverId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Please set a serverId to update Traefik ports", + }); + } + await initializeTraefik({ + serverId: input.serverId, + additionalPorts: input.additionalPorts, + }); + return true; + } catch (error) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + error instanceof Error + ? error.message + : "Error to update Traefik ports", + cause: error, + }); + } + }), + getTraefikPorts: adminProcedure + .input(apiServerSchema) + .query(async ({ input }) => { + const command = `docker service inspect --format='{{json .Endpoint.Ports}}' dokploy-traefik`; + + try { + let stdout = ""; + if (input?.serverId) { + const result = await execAsyncRemote(input.serverId, command); + stdout = result.stdout; + } else if (!IS_CLOUD) { + const result = await execAsync(command); + stdout = result.stdout; + } + + const ports: { + Protocol: string; + TargetPort: number; + PublishedPort: number; + PublishMode: string; + }[] = JSON.parse(stdout.trim()); + + // Filter out the default ports (80, 443, and optionally 8080) + const additionalPorts = ports + .filter((port) => ![80, 443, 8080].includes(port.PublishedPort)) + .map((port) => ({ + targetPort: port.TargetPort, + publishedPort: port.PublishedPort, + publishMode: port.PublishMode.toLowerCase() as "host" | "ingress", + })); + + return additionalPorts; + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to get Traefik ports", + cause: error, + }); + } + }), }); // { // "Parallelism": 1, diff --git a/apps/dokploy/server/api/routers/swarm.ts b/apps/dokploy/server/api/routers/swarm.ts new file mode 100644 index 00000000..c5a2d4c8 --- /dev/null +++ b/apps/dokploy/server/api/routers/swarm.ts @@ -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); + }), +}); diff --git a/apps/dokploy/tailwind.config.ts b/apps/dokploy/tailwind.config.ts index c4fa88ec..45b529af 100644 --- a/apps/dokploy/tailwind.config.ts +++ b/apps/dokploy/tailwind.config.ts @@ -87,7 +87,7 @@ const config = { }, }, }, - plugins: [require("tailwindcss-animate")], + plugins: [require("tailwindcss-animate"), require("fancy-ansi/plugin")], } satisfies Config; export default config; diff --git a/apps/dokploy/templates/onedev/docker-compose.yml b/apps/dokploy/templates/onedev/docker-compose.yml new file mode 100644 index 00000000..af4122cf --- /dev/null +++ b/apps/dokploy/templates/onedev/docker-compose.yml @@ -0,0 +1,12 @@ +--- +services: + onedev: + image: 1dev/server:11.6.6 + restart: always + + volumes: + - "/var/run/docker.sock:/var/run/docker.sock" + - "onedev-data:/opt/onedev" + +volumes: + onedev-data: \ No newline at end of file diff --git a/apps/dokploy/templates/onedev/index.ts b/apps/dokploy/templates/onedev/index.ts new file mode 100644 index 00000000..5dad1728 --- /dev/null +++ b/apps/dokploy/templates/onedev/index.ts @@ -0,0 +1,22 @@ +import { + type DomainSchema, + type Schema, + type Template, + generateRandomDomain, +} from "../utils"; + +export function generate(schema: Schema): Template { + const randomDomain = generateRandomDomain(schema); + + const domains: DomainSchema[] = [ + { + host: randomDomain, + port: 6610, + serviceName: "onedev", + }, + ]; + + return { + domains, + }; +} diff --git a/apps/dokploy/templates/plausible/docker-compose.yml b/apps/dokploy/templates/plausible/docker-compose.yml index 62ce5ece..bb267f65 100644 --- a/apps/dokploy/templates/plausible/docker-compose.yml +++ b/apps/dokploy/templates/plausible/docker-compose.yml @@ -26,7 +26,7 @@ services: hard: 262144 plausible: - image: ghcr.io/plausible/community-edition:v2.1.0 + image: ghcr.io/plausible/community-edition:v2.1.4 restart: always command: sh -c "sleep 10 && /entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh run" depends_on: diff --git a/apps/dokploy/templates/templates.ts b/apps/dokploy/templates/templates.ts index 39243356..917184c5 100644 --- a/apps/dokploy/templates/templates.ts +++ b/apps/dokploy/templates/templates.ts @@ -34,7 +34,7 @@ export const templates: TemplateData[] = [ { id: "plausible", name: "Plausible", - version: "v2.1.0", + version: "v2.1.4", description: "Plausible is a open source, self-hosted web analytics platform that lets you track website traffic and user behavior.", logo: "plausible.svg", @@ -1136,4 +1136,19 @@ export const templates: TemplateData[] = [ tags: ["search", "analytics"], load: () => import("./elastic-search/index").then((m) => m.generate), }, + { + id: "onedev", + name: "OneDev", + version: "11.6.6", + description: + "Git server with CI/CD, kanban, and packages. Seamless integration. Unparalleled experience.", + logo: "onedev.png", + links: { + github: "https://github.com/theonedev/onedev/", + website: "https://onedev.io/", + docs: "https://docs.onedev.io/", + }, + tags: ["self-hosted", "development"], + load: () => import("./onedev/index").then((m) => m.generate), + }, ]; diff --git a/packages/server/src/db/schema/utils.ts b/packages/server/src/db/schema/utils.ts index 59ebf4b7..43332c8a 100644 --- a/packages/server/src/db/schema/utils.ts +++ b/packages/server/src/db/schema/utils.ts @@ -1,3 +1,4 @@ +import { generatePassword } from "@dokploy/server/templates/utils"; import { faker } from "@faker-js/faker"; import { customAlphabet } from "nanoid"; @@ -13,3 +14,17 @@ export const generateAppName = (type: string) => { const nanoidPart = customNanoid(); 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); +}; diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index fef1457c..b8ecb88b 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -3,10 +3,10 @@ import { db } from "@dokploy/server/db"; import { type apiCreateApplication, applications, + buildAppName, + cleanAppName, } from "@dokploy/server/db/schema"; -import { generateAppName } from "@dokploy/server/db/schema"; import { getAdvancedStats } from "@dokploy/server/monitoring/utilts"; -import { generatePassword } from "@dokploy/server/templates/utils"; import { buildApplication, getBuildCommand, @@ -46,34 +46,31 @@ import { createDeploymentPreview, updateDeploymentStatus, } from "./deployment"; -import { validUniqueServerAppName } from "./project"; -import { - findPreviewDeploymentById, - updatePreviewDeployment, -} from "./preview-deployment"; +import { type Domain, getDomainHost } from "./domain"; import { createPreviewDeploymentComment, getIssueComment, issueCommentExists, updateIssueComment, } 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 const createApplication = async ( input: typeof apiCreateApplication._type, ) => { - input.appName = - `${input.appName}-${generatePassword(6)}` || generateAppName("app"); - if (input.appName) { - const valid = await validUniqueServerAppName(input.appName); + const appName = buildAppName("app", input.appName); - if (!valid) { - throw new TRPCError({ - code: "CONFLICT", - message: "Application with this 'AppName' already exists", - }); - } + const valid = await validUniqueServerAppName(appName); + if (!valid) { + throw new TRPCError({ + code: "CONFLICT", + message: "Application with this 'AppName' already exists", + }); } return await db.transaction(async (tx) => { @@ -81,6 +78,7 @@ export const createApplication = async ( .insert(applications) .values({ ...input, + appName, }) .returning() .then((value) => value[0]); @@ -140,10 +138,11 @@ export const updateApplication = async ( applicationId: string, applicationData: Partial, ) => { + const { appName, ...rest } = applicationData; const application = await db .update(applications) .set({ - ...applicationData, + ...rest, }) .where(eq(applications.applicationId, applicationId)) .returning(); diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index c4b88c68..385e553a 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -2,7 +2,7 @@ import { join } from "node:path"; import { paths } from "@dokploy/server/constants"; import { db } from "@dokploy/server/db"; 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 { buildCompose, @@ -52,17 +52,14 @@ import { validUniqueServerAppName } from "./project"; export type Compose = typeof compose.$inferSelect; export const createCompose = async (input: typeof apiCreateCompose._type) => { - input.appName = - `${input.appName}-${generatePassword(6)}` || generateAppName("compose"); - if (input.appName) { - const valid = await validUniqueServerAppName(input.appName); + const appName = buildAppName("compose", input.appName); - if (!valid) { - throw new TRPCError({ - code: "CONFLICT", - message: "Service with this 'AppName' already exists", - }); - } + const valid = await validUniqueServerAppName(appName); + if (!valid) { + throw new TRPCError({ + code: "CONFLICT", + message: "Service with this 'AppName' already exists", + }); } const newDestination = await db @@ -70,6 +67,7 @@ export const createCompose = async (input: typeof apiCreateCompose._type) => { .values({ ...input, composeFile: "", + appName, }) .returning() .then((value) => value[0]); @@ -87,8 +85,9 @@ export const createCompose = async (input: typeof apiCreateCompose._type) => { export const createComposeByTemplate = async ( input: typeof compose.$inferInsert, ) => { - if (input.appName) { - const valid = await validUniqueServerAppName(input.appName); + const appName = cleanAppName(input.appName); + if (appName) { + const valid = await validUniqueServerAppName(appName); if (!valid) { throw new TRPCError({ @@ -101,6 +100,7 @@ export const createComposeByTemplate = async ( .insert(compose) .values({ ...input, + appName, }) .returning() .then((value) => value[0]); @@ -184,10 +184,11 @@ export const updateCompose = async ( composeId: string, composeData: Partial, ) => { + const { appName, ...rest } = composeData; const composeResult = await db .update(compose) .set({ - ...composeData, + ...rest, }) .where(eq(compose.composeId, composeId)) .returning(); diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index b18b132d..41adf238 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -23,8 +23,8 @@ import { type Server, findServerById } from "./server"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { - findPreviewDeploymentById, type PreviewDeployment, + findPreviewDeploymentById, updatePreviewDeployment, } from "./preview-deployment"; diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index 6ac61354..60262ba1 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -224,3 +224,124 @@ export const containerRestart = async (containerId: string) => { return config; } catch (error) {} }; + +export const getSwarmNodes = async (serverId?: string) => { + try { + let stdout = ""; + let stderr = ""; + const command = "docker node ls --format '{{json .}}'"; + + if (serverId) { + const result = await execAsyncRemote(serverId, command); + stdout = result.stdout; + stderr = result.stderr; + } else { + const result = await execAsync(command); + stdout = result.stdout; + stderr = result.stderr; + } + + if (stderr) { + console.error(`Error: ${stderr}`); + return; + } + + const nodes = JSON.parse(stdout); + + const nodesArray = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + return nodesArray; + } catch (error) {} +}; + +export const getNodeInfo = async (nodeId: string, serverId?: string) => { + try { + const command = `docker node inspect ${nodeId} --format '{{json .}}'`; + let stdout = ""; + let stderr = ""; + if (serverId) { + const result = await execAsyncRemote(serverId, command); + stdout = result.stdout; + stderr = result.stderr; + } else { + const result = await execAsync(command); + stdout = result.stdout; + stderr = result.stderr; + } + + if (stderr) { + console.error(`Error: ${stderr}`); + return; + } + + const nodeInfo = JSON.parse(stdout); + + return nodeInfo; + } catch (error) {} +}; + +export const getNodeApplications = async (serverId?: string) => { + try { + let stdout = ""; + let stderr = ""; + const command = `docker service ls --format '{{json .}}'`; + + if (serverId) { + const result = await execAsyncRemote(serverId, command); + stdout = result.stdout; + stderr = result.stderr; + } else { + const result = await execAsync(command); + + stdout = result.stdout; + stderr = result.stderr; + } + + if (stderr) { + console.error(`Error: ${stderr}`); + return; + } + + const appArray = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + + return appArray; + } catch (error) {} +}; + +export const getApplicationInfo = async ( + appName: string, + serverId?: string, +) => { + try { + let stdout = ""; + let stderr = ""; + const command = `docker service ps ${appName} --format '{{json .}}'`; + + if (serverId) { + const result = await execAsyncRemote(serverId, command); + stdout = result.stdout; + stderr = result.stderr; + } else { + const result = await execAsync(command); + stdout = result.stdout; + stderr = result.stderr; + } + + if (stderr) { + console.error(`Error: ${stderr}`); + return; + } + + const appArray = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + + return appArray; + } catch (error) {} +}; diff --git a/packages/server/src/services/mariadb.ts b/packages/server/src/services/mariadb.ts index 645b5c65..39ef5910 100644 --- a/packages/server/src/services/mariadb.ts +++ b/packages/server/src/services/mariadb.ts @@ -4,7 +4,7 @@ import { backups, mariadb, } 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 { buildMariadb } from "@dokploy/server/utils/databases/mariadb"; 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 const createMariadb = async (input: typeof apiCreateMariaDB._type) => { - input.appName = - `${input.appName}-${generatePassword(6)}` || generateAppName("mariadb"); - if (input.appName) { - const valid = await validUniqueServerAppName(input.appName); + const appName = buildAppName("mariadb", input.appName); - if (!valid) { - throw new TRPCError({ - code: "CONFLICT", - message: "Service with this 'AppName' already exists", - }); - } + const valid = await validUniqueServerAppName(input.appName); + if (!valid) { + throw new TRPCError({ + code: "CONFLICT", + message: "Service with this 'AppName' already exists", + }); } const newMariadb = await db @@ -40,6 +37,7 @@ export const createMariadb = async (input: typeof apiCreateMariaDB._type) => { databaseRootPassword: input.databaseRootPassword ? input.databaseRootPassword : generatePassword(), + appName, }) .returning() .then((value) => value[0]); @@ -82,10 +80,11 @@ export const updateMariadbById = async ( mariadbId: string, mariadbData: Partial, ) => { + const { appName, ...rest } = mariadbData; const result = await db .update(mariadb) .set({ - ...mariadbData, + ...rest, }) .where(eq(mariadb.mariadbId, mariadbId)) .returning(); diff --git a/packages/server/src/services/mongo.ts b/packages/server/src/services/mongo.ts index b87ec4da..f8d5e4d6 100644 --- a/packages/server/src/services/mongo.ts +++ b/packages/server/src/services/mongo.ts @@ -1,6 +1,6 @@ import { db } from "@dokploy/server/db"; 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 { buildMongo } from "@dokploy/server/utils/databases/mongo"; 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 const createMongo = async (input: typeof apiCreateMongo._type) => { - input.appName = - `${input.appName}-${generatePassword(6)}` || generateAppName("mongo"); - if (input.appName) { - const valid = await validUniqueServerAppName(input.appName); + const appName = buildAppName("mongo", input.appName); - if (!valid) { - throw new TRPCError({ - code: "CONFLICT", - message: "Service with this 'AppName' already exists", - }); - } + const valid = await validUniqueServerAppName(appName); + if (!valid) { + throw new TRPCError({ + code: "CONFLICT", + message: "Service with this 'AppName' already exists", + }); } const newMongo = await db @@ -33,6 +30,7 @@ export const createMongo = async (input: typeof apiCreateMongo._type) => { databasePassword: input.databasePassword ? input.databasePassword : generatePassword(), + appName, }) .returning() .then((value) => value[0]); @@ -74,10 +72,11 @@ export const updateMongoById = async ( mongoId: string, mongoData: Partial, ) => { + const { appName, ...rest } = mongoData; const result = await db .update(mongo) .set({ - ...mongoData, + ...rest, }) .where(eq(mongo.mongoId, mongoId)) .returning(); diff --git a/packages/server/src/services/mysql.ts b/packages/server/src/services/mysql.ts index ee9df820..e2c8bb6f 100644 --- a/packages/server/src/services/mysql.ts +++ b/packages/server/src/services/mysql.ts @@ -1,6 +1,6 @@ import { db } from "@dokploy/server/db"; 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 { buildMysql } from "@dokploy/server/utils/databases/mysql"; 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 const createMysql = async (input: typeof apiCreateMySql._type) => { - input.appName = - `${input.appName}-${generatePassword(6)}` || generateAppName("mysql"); + const appName = buildAppName("mysql", input.appName); - if (input.appName) { - const valid = await validUniqueServerAppName(input.appName); - - if (!valid) { - throw new TRPCError({ - code: "CONFLICT", - message: "Service with this 'AppName' already exists", - }); - } + const valid = await validUniqueServerAppName(appName); + if (!valid) { + throw new TRPCError({ + code: "CONFLICT", + message: "Service with this 'AppName' already exists", + }); } const newMysql = await db @@ -37,6 +33,7 @@ export const createMysql = async (input: typeof apiCreateMySql._type) => { databaseRootPassword: input.databaseRootPassword ? input.databaseRootPassword : generatePassword(), + appName, }) .returning() .then((value) => value[0]); @@ -79,10 +76,11 @@ export const updateMySqlById = async ( mysqlId: string, mysqlData: Partial, ) => { + const { appName, ...rest } = mysqlData; const result = await db .update(mysql) .set({ - ...mysqlData, + ...rest, }) .where(eq(mysql.mysqlId, mysqlId)) .returning(); diff --git a/packages/server/src/services/postgres.ts b/packages/server/src/services/postgres.ts index c94ddbbe..87286e67 100644 --- a/packages/server/src/services/postgres.ts +++ b/packages/server/src/services/postgres.ts @@ -4,7 +4,7 @@ import { backups, postgres, } 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 { buildPostgres } from "@dokploy/server/utils/databases/postgres"; 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 const createPostgres = async (input: typeof apiCreatePostgres._type) => { - input.appName = - `${input.appName}-${generatePassword(6)}` || generateAppName("postgres"); - if (input.appName) { - const valid = await validUniqueServerAppName(input.appName); + const appName = buildAppName("postgres", input.appName); - if (!valid) { - throw new TRPCError({ - code: "CONFLICT", - message: "Service with this 'AppName' already exists", - }); - } + const valid = await validUniqueServerAppName(appName); + if (!valid) { + throw new TRPCError({ + code: "CONFLICT", + message: "Service with this 'AppName' already exists", + }); } const newPostgres = await db @@ -37,6 +34,7 @@ export const createPostgres = async (input: typeof apiCreatePostgres._type) => { databasePassword: input.databasePassword ? input.databasePassword : generatePassword(), + appName, }) .returning() .then((value) => value[0]); @@ -96,10 +94,11 @@ export const updatePostgresById = async ( postgresId: string, postgresData: Partial, ) => { + const { appName, ...rest } = postgresData; const result = await db .update(postgres) .set({ - ...postgresData, + ...rest, }) .where(eq(postgres.postgresId, postgresId)) .returning(); diff --git a/packages/server/src/services/preview-deployment.ts b/packages/server/src/services/preview-deployment.ts index e52f4553..06bf8fb5 100644 --- a/packages/server/src/services/preview-deployment.ts +++ b/packages/server/src/services/preview-deployment.ts @@ -7,20 +7,20 @@ import { import { TRPCError } from "@trpc/server"; import { and, desc, eq } from "drizzle-orm"; import { slugify } from "../setup/server-setup"; -import { findApplicationById } from "./application"; -import { createDomain } from "./domain"; 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 { findAdminById } from "./admin"; +import { findApplicationById } from "./application"; import { removeDeployments, removeDeploymentsByPreviewDeploymentId, } from "./deployment"; -import { removeDirectoryCode } from "../utils/filesystem/directory"; -import { removeTraefikConfig } from "../utils/traefik/application"; -import { removeService } from "../utils/docker/utils"; -import { authGithub } from "../utils/providers/github"; -import { getIssueComment, type Github } from "./github"; -import { findAdminById } from "./admin"; +import { createDomain } from "./domain"; +import { type Github, getIssueComment } from "./github"; export type PreviewDeployment = typeof previewDeployments.$inferSelect; diff --git a/packages/server/src/services/redis.ts b/packages/server/src/services/redis.ts index 7809de28..5b958081 100644 --- a/packages/server/src/services/redis.ts +++ b/packages/server/src/services/redis.ts @@ -1,6 +1,6 @@ import { db } from "@dokploy/server/db"; 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 { buildRedis } from "@dokploy/server/utils/databases/redis"; 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 export const createRedis = async (input: typeof apiCreateRedis._type) => { - input.appName = - `${input.appName}-${generatePassword(6)}` || generateAppName("redis"); - if (input.appName) { - const valid = await validUniqueServerAppName(input.appName); + const appName = buildAppName("redis", input.appName); - if (!valid) { - throw new TRPCError({ - code: "CONFLICT", - message: "Service with this 'AppName' already exists", - }); - } + const valid = await validUniqueServerAppName(appName); + if (!valid) { + throw new TRPCError({ + code: "CONFLICT", + message: "Service with this 'AppName' already exists", + }); } const newRedis = await db @@ -34,6 +31,7 @@ export const createRedis = async (input: typeof apiCreateRedis._type) => { databasePassword: input.databasePassword ? input.databasePassword : generatePassword(), + appName, }) .returning() .then((value) => value[0]); @@ -70,10 +68,11 @@ export const updateRedisById = async ( redisId: string, redisData: Partial, ) => { + const { appName, ...rest } = redisData; const result = await db .update(redis) .set({ - ...redisData, + ...rest, }) .where(eq(redis.redisId, redisId)) .returning(); diff --git a/packages/server/src/services/settings.ts b/packages/server/src/services/settings.ts index 8261843a..37f7b2ee 100644 --- a/packages/server/src/services/settings.ts +++ b/packages/server/src/services/settings.ts @@ -1,41 +1,108 @@ import { readdirSync } from "node:fs"; import { join } from "node:path"; import { docker } from "@dokploy/server/constants"; -import { getServiceContainer } from "@dokploy/server/utils/docker/utils"; -import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; +import { + execAsync, + execAsyncRemote, +} from "@dokploy/server/utils/process/execAsync"; // import packageInfo from "../../../package.json"; -const updateIsAvailable = async () => { - try { - const service = await getServiceContainer("dokploy"); +export interface IUpdateData { + latestVersion: string | null; + updateAvailable: boolean; +} - const localImage = await docker.getImage(getDokployImage()).inspect(); - return localImage.Id !== service?.ImageID; - } catch (error) { - return false; - } +export const DEFAULT_UPDATE_DATA: IUpdateData = { + latestVersion: null, + updateAvailable: false, +}; + +/** Returns current Dokploy docker image tag or `latest` by default. */ +export const getDokployImageTag = () => { + return process.env.RELEASE_TAG || "latest"; }; export const getDokployImage = () => { - return `dokploy/dokploy:${process.env.RELEASE_TAG || "latest"}`; + return `dokploy/dokploy:${getDokployImageTag()}`; }; export const pullLatestRelease = async () => { - try { - const stream = await docker.pull(getDokployImage(), {}); - await new Promise((resolve, reject) => { - docker.modem.followProgress(stream, (err, res) => - err ? reject(err) : resolve(res), - ); - }); - const newUpdateIsAvailable = await updateIsAvailable(); - return newUpdateIsAvailable; - } catch (error) {} - - return false; + const stream = await docker.pull(getDokployImage()); + await new Promise((resolve, reject) => { + docker.modem.followProgress(stream, (err, res) => + err ? reject(err) : resolve(res), + ); + }); }; -export const getDokployVersion = () => { - // return packageInfo.version; + +/** Returns Dokploy docker service image digest */ +export const getServiceImageDigest = async () => { + const { stdout } = await execAsync( + "docker service inspect dokploy --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}'", + ); + + const currentDigest = stdout.trim().split("@")[1]; + + if (!currentDigest) { + throw new Error("Could not get current service image digest"); + } + + return currentDigest; +}; + +/** Returns latest version number and information whether server update is available by comparing current image's digest against digest for provided image tag via Docker hub API. */ +export const getUpdateData = async (): Promise => { + let currentDigest: string; + try { + currentDigest = await getServiceImageDigest(); + } catch { + // Docker service might not exist locally + // You can run the # Installation command for docker service create mentioned in the below docs to test it locally: + // https://docs.dokploy.com/docs/core/manual-installation + return DEFAULT_UPDATE_DATA; + } + + const baseUrl = "https://hub.docker.com/v2/repositories/dokploy/dokploy/tags"; + let url: string | null = `${baseUrl}?page_size=100`; + let allResults: { digest: string; name: string }[] = []; + while (url) { + const response = await fetch(url, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + const data = (await response.json()) as { + next: string | null; + results: { digest: string; name: string }[]; + }; + + allResults = allResults.concat(data.results); + url = data?.next; + } + + const imageTag = getDokployImageTag(); + const searchedDigest = allResults.find((t) => t.name === imageTag)?.digest; + + if (!searchedDigest) { + return DEFAULT_UPDATE_DATA; + } + + if (imageTag === "latest") { + const versionedTag = allResults.find( + (t) => t.digest === searchedDigest && t.name.startsWith("v"), + ); + + if (!versionedTag) { + return DEFAULT_UPDATE_DATA; + } + + const { name: latestVersion, digest } = versionedTag; + const updateAvailable = digest !== currentDigest; + + return { latestVersion, updateAvailable }; + } + const updateAvailable = searchedDigest !== currentDigest; + return { latestVersion: imageTag, updateAvailable }; }; interface TreeDataItem { diff --git a/packages/server/src/setup/traefik-setup.ts b/packages/server/src/setup/traefik-setup.ts index 82832027..5e994dd2 100644 --- a/packages/server/src/setup/traefik-setup.ts +++ b/packages/server/src/setup/traefik-setup.ts @@ -16,12 +16,18 @@ interface TraefikOptions { enableDashboard?: boolean; env?: string[]; serverId?: string; + additionalPorts?: { + targetPort: number; + publishedPort: number; + publishMode?: "ingress" | "host"; + }[]; } export const initializeTraefik = async ({ enableDashboard = false, env, serverId, + additionalPorts = [], }: TraefikOptions = {}) => { const { MAIN_TRAEFIK_PATH, DYNAMIC_TRAEFIK_PATH } = paths(!!serverId); const imageName = "traefik:v3.1.2"; @@ -84,6 +90,11 @@ export const initializeTraefik = async ({ }, ] : []), + ...additionalPorts.map((port) => ({ + TargetPort: port.targetPort, + PublishedPort: port.publishedPort, + PublishMode: port.publishMode || ("host" as const), + })), ], }, }; diff --git a/packages/server/src/utils/backups/index.ts b/packages/server/src/utils/backups/index.ts index b1619253..797feb38 100644 --- a/packages/server/src/utils/backups/index.ts +++ b/packages/server/src/utils/backups/index.ts @@ -11,6 +11,8 @@ import { runMariadbBackup } from "./mariadb"; import { runMongoBackup } from "./mongo"; import { runMySqlBackup } from "./mysql"; import { runPostgresBackup } from "./postgres"; +import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup"; +import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; export const initCronJobs = async () => { console.log("Setting up cron jobs...."); @@ -25,14 +27,15 @@ export const initCronJobs = async () => { await cleanUpUnusedImages(); await cleanUpDockerBuilder(); await cleanUpSystemPrune(); + await sendDockerCleanupNotifications(admin.adminId); }); } const servers = await getAllServers(); for (const server of servers) { - const { appName, serverId } = server; - if (serverId) { + const { appName, serverId, enableDockerCleanup } = server; + if (enableDockerCleanup) { scheduleJob(serverId, "0 0 * * *", async () => { console.log( `SERVER-BACKUP[${new Date().toLocaleString()}] Running Cleanup ${appName}`, @@ -40,12 +43,17 @@ export const initCronJobs = async () => { await cleanUpUnusedImages(serverId); await cleanUpDockerBuilder(serverId); await cleanUpSystemPrune(serverId); + await sendDockerCleanupNotifications( + admin.adminId, + `Docker cleanup for Server ${appName}`, + ); }); } } const pgs = await db.query.postgres.findMany({ with: { + project: true, backups: { with: { destination: true, @@ -61,18 +69,39 @@ export const initCronJobs = async () => { for (const backup of pg.backups) { const { schedule, backupId, enabled } = backup; if (enabled) { - scheduleJob(backupId, schedule, async () => { - console.log( - `PG-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`, - ); - runPostgresBackup(pg, backup); - }); + try { + scheduleJob(backupId, schedule, async () => { + console.log( + `PG-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`, + ); + runPostgresBackup(pg, backup); + }); + + await sendDatabaseBackupNotifications({ + applicationName: pg.name, + projectName: pg.project.name, + databaseType: "postgres", + type: "success", + adminId: pg.project.adminId, + }); + } catch (error) { + await sendDatabaseBackupNotifications({ + applicationName: pg.name, + projectName: pg.project.name, + databaseType: "postgres", + type: "error", + // @ts-ignore + errorMessage: error?.message || "Error message not provided", + adminId: pg.project.adminId, + }); + } } } } const mariadbs = await db.query.mariadb.findMany({ with: { + project: true, backups: { with: { destination: true, @@ -89,18 +118,38 @@ export const initCronJobs = async () => { for (const backup of maria.backups) { const { schedule, backupId, enabled } = backup; if (enabled) { - scheduleJob(backupId, schedule, async () => { - console.log( - `MARIADB-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`, - ); - await runMariadbBackup(maria, backup); - }); + try { + scheduleJob(backupId, schedule, async () => { + console.log( + `MARIADB-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`, + ); + await runMariadbBackup(maria, backup); + }); + await sendDatabaseBackupNotifications({ + applicationName: maria.name, + projectName: maria.project.name, + databaseType: "mariadb", + type: "success", + adminId: maria.project.adminId, + }); + } catch (error) { + await sendDatabaseBackupNotifications({ + applicationName: maria.name, + projectName: maria.project.name, + databaseType: "mariadb", + type: "error", + // @ts-ignore + errorMessage: error?.message || "Error message not provided", + adminId: maria.project.adminId, + }); + } } } } const mongodbs = await db.query.mongo.findMany({ with: { + project: true, backups: { with: { destination: true, @@ -117,18 +166,38 @@ export const initCronJobs = async () => { for (const backup of mongo.backups) { const { schedule, backupId, enabled } = backup; if (enabled) { - scheduleJob(backupId, schedule, async () => { - console.log( - `MONGO-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`, - ); - await runMongoBackup(mongo, backup); - }); + try { + scheduleJob(backupId, schedule, async () => { + console.log( + `MONGO-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`, + ); + await runMongoBackup(mongo, backup); + }); + await sendDatabaseBackupNotifications({ + applicationName: mongo.name, + projectName: mongo.project.name, + databaseType: "mongodb", + type: "success", + adminId: mongo.project.adminId, + }); + } catch (error) { + await sendDatabaseBackupNotifications({ + applicationName: mongo.name, + projectName: mongo.project.name, + databaseType: "mongodb", + type: "error", + // @ts-ignore + errorMessage: error?.message || "Error message not provided", + adminId: mongo.project.adminId, + }); + } } } } const mysqls = await db.query.mysql.findMany({ with: { + project: true, backups: { with: { destination: true, @@ -145,12 +214,31 @@ export const initCronJobs = async () => { for (const backup of mysql.backups) { const { schedule, backupId, enabled } = backup; if (enabled) { - scheduleJob(backupId, schedule, async () => { - console.log( - `MYSQL-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`, - ); - await runMySqlBackup(mysql, backup); - }); + try { + scheduleJob(backupId, schedule, async () => { + console.log( + `MYSQL-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`, + ); + await runMySqlBackup(mysql, backup); + }); + await sendDatabaseBackupNotifications({ + applicationName: mysql.name, + projectName: mysql.project.name, + databaseType: "mysql", + type: "success", + adminId: mysql.project.adminId, + }); + } catch (error) { + await sendDatabaseBackupNotifications({ + applicationName: mysql.name, + projectName: mysql.project.name, + databaseType: "mysql", + type: "error", + // @ts-ignore + errorMessage: error?.message || "Error message not provided", + adminId: mysql.project.adminId, + }); + } } } } diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts index 3e64ed35..40f8d6d5 100644 --- a/packages/server/src/utils/builders/compose.ts +++ b/packages/server/src/utils/builders/compose.ts @@ -48,6 +48,7 @@ Compose Type: ${composeType} ✅`; writeStream.write(`\n${logBox}\n`); const projectPath = join(COMPOSE_PATH, compose.appName, "code"); + await spawnAsync( "docker", [...command.split(" ")], @@ -144,6 +145,10 @@ const sanitizeCommand = (command: string) => { export const createCommand = (compose: ComposeNested) => { const { composeType, appName, sourceType } = compose; + if (compose.command) { + return `${sanitizeCommand(compose.command)}`; + } + const path = sourceType === "raw" ? "docker-compose.yml" : compose.composePath; let command = ""; @@ -154,12 +159,6 @@ export const createCommand = (compose: ComposeNested) => { command = `stack deploy -c ${path} ${appName} --prune`; } - const customCommand = sanitizeCommand(compose.command); - - if (customCommand) { - command = `${command} ${customCommand}`; - } - return command; }; diff --git a/packages/server/src/utils/builders/index.ts b/packages/server/src/utils/builders/index.ts index 1cdc9787..ce10413a 100644 --- a/packages/server/src/utils/builders/index.ts +++ b/packages/server/src/utils/builders/index.ts @@ -211,21 +211,21 @@ const getImageName = (application: ApplicationNested) => { } if (registry) { - return join(registry.imagePrefix || "", appName); + return join(registry.registryUrl, registry.imagePrefix || "", appName); } return `${appName}:latest`; }; const getAuthConfig = (application: ApplicationNested) => { - const { registry, username, password, sourceType } = application; + const { registry, username, password, sourceType, registryUrl } = application; if (sourceType === "docker") { if (username && password) { return { password, username, - serveraddress: "https://index.docker.io/v1/", + serveraddress: registryUrl || "", }; } } else if (registry) { diff --git a/packages/server/src/utils/cluster/upload.ts b/packages/server/src/utils/cluster/upload.ts index 22c9881c..980ace67 100644 --- a/packages/server/src/utils/cluster/upload.ts +++ b/packages/server/src/utils/cluster/upload.ts @@ -1,5 +1,5 @@ import type { WriteStream } from "node:fs"; -import { join } from "node:path"; +import path, { join } from "node:path"; import type { ApplicationNested } from "../builders"; import { spawnAsync } from "../process/spawnAsync"; @@ -13,27 +13,32 @@ export const uploadImage = async ( throw new Error("Registry not found"); } - const { registryUrl, imagePrefix, registryType } = registry; + const { registryUrl, imagePrefix } = registry; const { appName } = application; const imageName = `${appName}:latest`; const finalURL = registryUrl; - const registryTag = join(imagePrefix || "", imageName); + const registryTag = path + .join(registryUrl, join(imagePrefix || "", imageName)) + .replace(/\/+/g, "/"); try { writeStream.write( - `📦 [Enabled Registry] Uploading image to ${registry.registryType} | ${registryTag} | ${finalURL}\n`, + `📦 [Enabled Registry] Uploading image to ${registry.registryType} | ${imageName} | ${finalURL}\n`, ); - await spawnAsync( + const loginCommand = spawnAsync( "docker", - ["login", finalURL, "-u", registry.username, "-p", registry.password], + ["login", finalURL, "-u", registry.username, "--password-stdin"], (data) => { if (writeStream.writable) { writeStream.write(data); } }, ); + loginCommand.child?.stdin?.write(registry.password); + loginCommand.child?.stdin?.end(); + await loginCommand; await spawnAsync("docker", ["tag", imageName, registryTag], (data) => { if (writeStream.writable) { @@ -68,22 +73,23 @@ export const uploadImageRemoteCommand = ( const finalURL = registryUrl; - const registryTag = join(imagePrefix || "", imageName); + const registryTag = path + .join(registryUrl, join(imagePrefix || "", imageName)) + .replace(/\/+/g, "/"); try { const command = ` echo "📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'" >> ${logPath}; - docker login ${finalURL} -u ${registry.username} -p ${registry.password} >> ${logPath} 2>> ${logPath} || { + echo "${registry.password}" | docker login ${finalURL} -u ${registry.username} --password-stdin >> ${logPath} 2>> ${logPath} || { echo "❌ DockerHub Failed" >> ${logPath}; exit 1; } - echo "✅ DockerHub Login Success" >> ${logPath}; + echo "✅ Registry Login Success" >> ${logPath}; docker tag ${imageName} ${registryTag} >> ${logPath} 2>> ${logPath} || { echo "❌ Error tagging image" >> ${logPath}; exit 1; } - echo "✅ Image Tagged" >> ${logPath}; - + echo "✅ Image Tagged" >> ${logPath}; docker push ${registryTag} 2>> ${logPath} || { echo "❌ Error pushing image" >> ${logPath}; exit 1; @@ -92,7 +98,6 @@ export const uploadImageRemoteCommand = ( `; return command; } catch (error) { - console.log(error); throw error; } }; diff --git a/packages/server/src/utils/notifications/build-error.ts b/packages/server/src/utils/notifications/build-error.ts index 5406698d..f9286fc7 100644 --- a/packages/server/src/utils/notifications/build-error.ts +++ b/packages/server/src/utils/notifications/build-error.ts @@ -28,7 +28,7 @@ export const sendBuildErrorNotifications = async ({ adminId, }: Props) => { const date = new Date(); - const unixDate = ~~((Number(date)) / 1000); + const unixDate = ~~(Number(date) / 1000); const notificationList = await db.query.notifications.findMany({ where: and( eq(notifications.appBuildError, true), @@ -60,45 +60,45 @@ export const sendBuildErrorNotifications = async ({ if (discord) { await sendDiscordNotification(discord, { - title: "> `⚠️` - Build Failed", + title: "> `⚠️` Build Failed", color: 0xed4245, fields: [ { - name: "`🛠️`・Project", + name: "`🛠️` Project", value: projectName, inline: true, }, { - name: "`⚙️`・Application", + name: "`⚙️` Application", value: applicationName, inline: true, }, { - name: "`❔`・Type", + name: "`❔` Type", value: applicationType, inline: true, }, { - name: "`📅`・Date", + name: "`📅` Date", value: ``, inline: true, }, { - name: "`⌚`・Time", + name: "`⌚` Time", value: ``, inline: true, }, { - name: "`❓`・Type", + name: "`❓`Type", value: "Failed", inline: true, }, { - name: "`⚠️`・Error Message", + name: "`⚠️` Error Message", value: `\`\`\`${errorMessage}\`\`\``, }, { - name: "`🧷`・Build Link", + name: "`🧷` Build Link", value: `[Click here to access build link](${buildLink})`, }, ], diff --git a/packages/server/src/utils/notifications/build-success.ts b/packages/server/src/utils/notifications/build-success.ts index 1c16b10d..97fe7e1c 100644 --- a/packages/server/src/utils/notifications/build-success.ts +++ b/packages/server/src/utils/notifications/build-success.ts @@ -26,7 +26,7 @@ export const sendBuildSuccessNotifications = async ({ adminId, }: Props) => { const date = new Date(); - const unixDate = ~~((Number(date)) / 1000); + const unixDate = ~~(Number(date) / 1000); const notificationList = await db.query.notifications.findMany({ where: and( eq(notifications.appDeploy, true), @@ -58,41 +58,41 @@ export const sendBuildSuccessNotifications = async ({ if (discord) { await sendDiscordNotification(discord, { - title: "> `✅` - Build Success", + title: "> `✅` Build Success", color: 0x57f287, fields: [ { - name: "`🛠️`・Project", + name: "`🛠️` Project", value: projectName, inline: true, }, { - name: "`⚙️`・Application", + name: "`⚙️` Application", value: applicationName, inline: true, }, { - name: "`❔`・Application Type", + name: "`❔` Application Type", value: applicationType, inline: true, }, { - name: "`📅`・Date", + name: "`📅` Date", value: ``, inline: true, }, { - name: "`⌚`・Time", + name: "`⌚` Time", value: ``, inline: true, }, { - name: "`❓`・Type", + name: "`❓` Type", value: "Successful", inline: true, }, { - name: "`🧷`・Build Link", + name: "`🧷` Build Link", value: `[Click here to access build link](${buildLink})`, }, ], diff --git a/packages/server/src/utils/notifications/database-backup.ts b/packages/server/src/utils/notifications/database-backup.ts index 6d3dcb28..d2dccdaf 100644 --- a/packages/server/src/utils/notifications/database-backup.ts +++ b/packages/server/src/utils/notifications/database-backup.ts @@ -26,7 +26,7 @@ export const sendDatabaseBackupNotifications = async ({ errorMessage?: string; }) => { const date = new Date(); - const unixDate = ~~((Number(date)) / 1000); + const unixDate = ~~(Number(date) / 1000); const notificationList = await db.query.notifications.findMany({ where: and( eq(notifications.databaseBackup, true), @@ -65,37 +65,37 @@ export const sendDatabaseBackupNotifications = async ({ await sendDiscordNotification(discord, { title: type === "success" - ? "> `✅` - Database Backup Successful" - : "> `❌` - Database Backup Failed", + ? "> `✅` Database Backup Successful" + : "> `❌` Database Backup Failed", color: type === "success" ? 0x57f287 : 0xed4245, fields: [ { - name: "`🛠️`・Project", + name: "`🛠️` Project", value: projectName, inline: true, }, { - name: "`⚙️`・Application", + name: "`⚙️` Application", value: applicationName, inline: true, }, { - name: "`❔`・Database", + name: "`❔` Database", value: databaseType, inline: true, }, { - name: "`📅`・Date", + name: "`📅` Date", value: ``, inline: true, }, { - name: "`⌚`・Time", + name: "`⌚` Time", value: ``, inline: true, }, { - name: "`❓`・Type", + name: "`❓` Type", value: type .replace("error", "Failed") .replace("success", "Successful"), @@ -104,7 +104,7 @@ export const sendDatabaseBackupNotifications = async ({ ...(type === "error" && errorMessage ? [ { - name: "`⚠️`・Error Message", + name: "`⚠️` Error Message", value: `\`\`\`${errorMessage}\`\`\``, }, ] diff --git a/packages/server/src/utils/notifications/docker-cleanup.ts b/packages/server/src/utils/notifications/docker-cleanup.ts index 7a836329..515d1ddc 100644 --- a/packages/server/src/utils/notifications/docker-cleanup.ts +++ b/packages/server/src/utils/notifications/docker-cleanup.ts @@ -15,7 +15,7 @@ export const sendDockerCleanupNotifications = async ( message = "Docker cleanup for dokploy", ) => { const date = new Date(); - const unixDate = ~~((Number(date)) / 1000); + const unixDate = ~~(Number(date) / 1000); const notificationList = await db.query.notifications.findMany({ where: and( eq(notifications.dockerCleanup, true), @@ -46,26 +46,26 @@ export const sendDockerCleanupNotifications = async ( if (discord) { await sendDiscordNotification(discord, { - title: "> `✅` - Docker Cleanup", + title: "> `✅` Docker Cleanup", color: 0x57f287, fields: [ { - name: "`📅`・Date", + name: "`📅` Date", value: ``, inline: true, }, { - name: "`⌚`・Time", + name: "`⌚` Time", value: ``, inline: true, }, { - name: "`❓`・Type", + name: "`❓` Type", value: "Successful", inline: true, }, { - name: "`📜`・Message", + name: "`📜` Message", value: `\`\`\`${message}\`\`\``, }, ], diff --git a/packages/server/src/utils/notifications/dokploy-restart.ts b/packages/server/src/utils/notifications/dokploy-restart.ts index 86cd6f03..883a3d1f 100644 --- a/packages/server/src/utils/notifications/dokploy-restart.ts +++ b/packages/server/src/utils/notifications/dokploy-restart.ts @@ -12,7 +12,7 @@ import { export const sendDokployRestartNotifications = async () => { const date = new Date(); - const unixDate = ~~((Number(date)) / 1000); + const unixDate = ~~(Number(date) / 1000); const notificationList = await db.query.notifications.findMany({ where: eq(notifications.dokployRestart, true), with: { @@ -35,21 +35,21 @@ export const sendDokployRestartNotifications = async () => { if (discord) { await sendDiscordNotification(discord, { - title: "> `✅` - Dokploy Server Restarted", + title: "> `✅` Dokploy Server Restarted", color: 0x57f287, fields: [ { - name: "`📅`・Date", + name: "`📅` Date", value: ``, inline: true, }, { - name: "`⌚`・Time", + name: "`⌚` Time", value: ``, inline: true, }, { - name: "`❓`・Type", + name: "`❓` Type", value: "Successful", inline: true, }, diff --git a/packages/server/src/utils/providers/docker.ts b/packages/server/src/utils/providers/docker.ts index 7245dc51..88c45776 100644 --- a/packages/server/src/utils/providers/docker.ts +++ b/packages/server/src/utils/providers/docker.ts @@ -53,7 +53,7 @@ export const buildRemoteDocker = async ( application: ApplicationNested, logPath: string, ) => { - const { sourceType, dockerImage, username, password } = application; + const { registryUrl, dockerImage, username, password } = application; try { if (!dockerImage) { @@ -65,7 +65,7 @@ echo "Pulling ${dockerImage}" >> ${logPath}; if (username && password) { command += ` -if ! docker login --username ${username} --password ${password} https://index.docker.io/v1/ >> ${logPath} 2>&1; then +if ! echo "${password}" | docker login --username "${username}" --password-stdin "${registryUrl || ""}" >> ${logPath} 2>&1; then echo "❌ Login failed" >> ${logPath}; exit 1; fi diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62ba2f56..df2dc8e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -250,6 +250,9 @@ importers: drizzle-zod: specifier: 0.5.1 version: 0.5.1(drizzle-orm@0.30.10(@types/react@18.3.5)(postgres@3.4.4)(react@18.2.0))(zod@3.23.8) + fancy-ansi: + specifier: ^0.1.3 + version: 0.1.3 i18next: specifier: ^23.16.4 version: 23.16.5 @@ -874,9 +877,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' '@esbuild/aix-ppc64@0.19.12': resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} @@ -4401,6 +4406,9 @@ packages: resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} @@ -4460,6 +4468,9 @@ packages: ext@1.7.0: resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} + fancy-ansi@0.1.3: + resolution: {integrity: sha512-tRQVTo5jjdSIiydqgzIIEZpKddzSsfGLsSVt6vWdjVm7fbvDTiQkyoPu6Z3dIPlAM4OZk0jP5jmTCX4G8WGgBw==} + fast-copy@3.0.2: resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} @@ -10735,6 +10746,8 @@ snapshots: escalade@3.1.2: {} + escape-html@1.0.3: {} + escape-string-regexp@1.0.5: {} escape-string-regexp@5.0.0: {} @@ -10795,6 +10808,10 @@ snapshots: dependencies: type: 2.7.3 + fancy-ansi@0.1.3: + dependencies: + escape-html: 1.0.3 + fast-copy@3.0.2: {} fast-deep-equal@2.0.1: {}