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) => ( 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..2f247e25 100644 --- a/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx @@ -9,7 +9,7 @@ import { import { cn } from "@/lib/utils"; import { escapeRegExp } from "lodash"; import React from "react"; -import { type LogLine, getLogType } from "./utils"; +import { type LogLine, getLogType, parseAnsi } from "./utils"; interface LogLineProps { log: LogLine; @@ -33,18 +33,38 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) { : "--- No time found ---"; const highlightMessage = (text: string, term: string) => { - if (!term) return text; - - const parts = text.split(new RegExp(`(${escapeRegExp(term)})`, "gi")); - return parts.map((part, index) => - part.toLowerCase() === term.toLowerCase() ? ( - - {part} + if (!term) { + const segments = parseAnsi(text); + return segments.map((segment, index) => ( + + {segment.text} - ) : ( - part - ), - ); + )); + } + + // For search, we need to handle both ANSI and search highlighting + const segments = parseAnsi(text); + return segments.map((segment, index) => { + const parts = segment.text.split( + new RegExp(`(${escapeRegExp(term)})`, "gi"), + ); + return ( + + {parts.map((part, partIndex) => + part.toLowerCase() === term.toLowerCase() ? ( + + {part} + + ) : ( + part + ), + )} + + ); + }); }; const tooltip = (color: string, timestamp: string | null) => { @@ -104,7 +124,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..48219428 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; @@ -12,6 +12,47 @@ interface LogStyle { variant: LogVariant; color: string; } +interface AnsiSegment { + text: string; + className: string; +} + +const ansiToTailwind: Record = { + // Reset + 0: "", + // Regular colors + 30: "text-black dark:text-gray-900", + 31: "text-red-600 dark:text-red-500", + 32: "text-green-600 dark:text-green-500", + 33: "text-yellow-600 dark:text-yellow-500", + 34: "text-blue-600 dark:text-blue-500", + 35: "text-purple-600 dark:text-purple-500", + 36: "text-cyan-600 dark:text-cyan-500", + 37: "text-gray-600 dark:text-gray-400", + // Bright colors + 90: "text-gray-500 dark:text-gray-600", + 91: "text-red-500 dark:text-red-600", + 92: "text-green-500 dark:text-green-600", + 93: "text-yellow-500 dark:text-yellow-600", + 94: "text-blue-500 dark:text-blue-600", + 95: "text-purple-500 dark:text-purple-600", + 96: "text-cyan-500 dark:text-cyan-600", + 97: "text-white dark:text-gray-300", + // Background colors + 40: "bg-black", + 41: "bg-red-600", + 42: "bg-green-600", + 43: "bg-yellow-600", + 44: "bg-blue-600", + 45: "bg-purple-600", + 46: "bg-cyan-600", + 47: "bg-white", + // Formatting + 1: "font-bold", + 2: "opacity-75", + 3: "italic", + 4: "underline", +}; const LOG_STYLES: Record = { error: { @@ -138,11 +179,68 @@ 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; } return LOG_STYLES.info; }; + +export function parseAnsi(text: string) { + const segments: { text: string; className: string }[] = []; + let currentIndex = 0; + let currentClasses: string[] = []; + + while (currentIndex < text.length) { + const escStart = text.indexOf("\x1b[", currentIndex); + + // No more escape sequences found + if (escStart === -1) { + if (currentIndex < text.length) { + segments.push({ + text: text.slice(currentIndex), + className: currentClasses.join(" "), + }); + } + break; + } + + // Add text before escape sequence + if (escStart > currentIndex) { + segments.push({ + text: text.slice(currentIndex, escStart), + className: currentClasses.join(" "), + }); + } + + const escEnd = text.indexOf("m", escStart); + if (escEnd === -1) break; + + // Handle multiple codes in one sequence (e.g., \x1b[1;31m) + const codesStr = text.slice(escStart + 2, escEnd); + const codes = codesStr.split(";").map((c) => Number.parseInt(c, 10)); + + if (codes.includes(0)) { + // Reset all formatting + currentClasses = []; + } else { + // Add new classes for each code + for (const code of codes) { + const className = ansiToTailwind[code]; + if (className && !currentClasses.includes(className)) { + currentClasses.push(className); + } + } + } + + currentIndex = escEnd + 1; + } + + return segments; +} \ No newline at end of file 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..10ea7304 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..cfa2e0ba 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/web-server/manage-traefik-ports.tsx b/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx index aa9741ce..965948ca 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/manage-traefik-ports.tsx @@ -49,12 +49,12 @@ interface AdditionalPort { /** * 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 @@ -121,7 +121,10 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
{additionalPorts.map((port, index) => ( -
+
+ {isUpdateAvailable && ( +
+ +
+ )} { } 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..39c075a6 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", 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/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 7561bf3e..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 }) => { 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/templates.ts b/apps/dokploy/templates/templates.ts index bab1f4b0..917184c5 100644 --- a/apps/dokploy/templates/templates.ts +++ b/apps/dokploy/templates/templates.ts @@ -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/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/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/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