diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx index 8c15e2cd..96e32d8c 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployment.tsx @@ -6,6 +6,10 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useEffect, useRef, useState } from "react"; +import { TerminalLine } from "../../docker/logs/terminal-line"; +import { LogLine, parseLogs } from "../../docker/logs/utils"; +import { Badge } from "@/components/ui/badge"; +import { Loader2 } from "lucide-react"; interface Props { logPath: string | null; @@ -15,9 +19,26 @@ interface Props { } export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => { const [data, setData] = useState(""); - const endOfLogsRef = useRef(null); + const [filteredLogs, setFilteredLogs] = useState([]); const wsRef = useRef(null); // Ref to hold WebSocket instance + const [autoScroll, setAutoScroll] = useState(true); + const scrollRef = useRef(null); + + const scrollToBottom = () => { + if (autoScroll && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }; + + const handleScroll = () => { + if (!scrollRef.current) return; + + const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; + const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10; + setAutoScroll(isAtBottom); + }; + useEffect(() => { if (!open || !logPath) return; @@ -48,13 +69,20 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => { }; }, [logPath, open]); - const scrollToBottom = () => { - endOfLogsRef.current?.scrollIntoView({ behavior: "smooth" }); - }; + + useEffect(() => { + const logs = parseLogs(data); + setFilteredLogs(logs); + }, [data]); useEffect(() => { scrollToBottom(); - }, [data]); + + if (autoScroll && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [filteredLogs, autoScroll]); + return ( { Deployment - See all the details of this deployment + See all the details of this deployment | {filteredLogs.length} lines -
- -
-							{data || "Loading..."}
-						
-
- +
{ + filteredLogs.length > 0 ? filteredLogs.map((log: LogLine, index: number) => ( + + )) : + ( +
+ +
+ )}
diff --git a/apps/dokploy/components/dashboard/application/logs/show.tsx b/apps/dokploy/components/dashboard/application/logs/show.tsx index 33beab12..dba3666c 100644 --- a/apps/dokploy/components/dashboard/application/logs/show.tsx +++ b/apps/dokploy/components/dashboard/application/logs/show.tsx @@ -90,7 +90,6 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { diff --git a/apps/dokploy/components/dashboard/compose/deployments/show-deployment-compose.tsx b/apps/dokploy/components/dashboard/compose/deployments/show-deployment-compose.tsx index 14f3bcd7..c8746b71 100644 --- a/apps/dokploy/components/dashboard/compose/deployments/show-deployment-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/deployments/show-deployment-compose.tsx @@ -6,6 +6,11 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useEffect, useRef, useState } from "react"; +import { TerminalLine } from "../../docker/logs/terminal-line"; +import { LogLine, parseLogs } from "../../docker/logs/utils"; +import { Badge } from "@/components/ui/badge"; +import { Loader2 } from "lucide-react"; + interface Props { logPath: string | null; @@ -20,9 +25,26 @@ export const ShowDeploymentCompose = ({ serverId, }: Props) => { const [data, setData] = useState(""); - const endOfLogsRef = useRef(null); + const [filteredLogs, setFilteredLogs] = useState([]); const wsRef = useRef(null); // Ref to hold WebSocket instance + const [autoScroll, setAutoScroll] = useState(true); + const scrollRef = useRef(null); + const scrollToBottom = () => { + if (autoScroll && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }; + + const handleScroll = () => { + if (!scrollRef.current) return; + + const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; + const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10; + setAutoScroll(isAtBottom); + }; + + useEffect(() => { if (!open || !logPath) return; @@ -54,13 +76,19 @@ export const ShowDeploymentCompose = ({ }; }, [logPath, open]); - const scrollToBottom = () => { - endOfLogsRef.current?.scrollIntoView({ behavior: "smooth" }); - }; + + useEffect(() => { + const logs = parseLogs(data); + setFilteredLogs(logs); + }, [data]); useEffect(() => { scrollToBottom(); - }, [data]); + + if (autoScroll && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [filteredLogs, autoScroll]); return ( - + Deployment - See all the details of this deployment + See all the details of this deployment | {filteredLogs.length} lines -
- -
-							{data || "Loading..."}
-						
-
- +
+ + + { + filteredLogs.length > 0 ? filteredLogs.map((log: LogLine, index: number) => ( + + )) : + ( +
+ +
+ ) + }
diff --git a/apps/dokploy/components/dashboard/compose/logs/show.tsx b/apps/dokploy/components/dashboard/compose/logs/show.tsx index 99208694..6b39f413 100644 --- a/apps/dokploy/components/dashboard/compose/logs/show.tsx +++ b/apps/dokploy/components/dashboard/compose/logs/show.tsx @@ -96,7 +96,6 @@ export const ShowDockerLogsCompose = ({ 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 6fc0ab48..db1c774b 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -1,115 +1,309 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Terminal } from "@xterm/xterm"; +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 { FitAddon } from "xterm-addon-fit"; -import "@xterm/xterm/css/xterm.css"; +import { TerminalLine } from "./terminal-line"; +import { type LogLine, getLogType, parseLogs } from "./utils"; interface Props { - id: string; - containerId: string; - serverId?: string | null; + containerId: string; + serverId?: string | null; } -export const DockerLogsId: React.FC = ({ - id, - containerId, - serverId, -}) => { - const [term, setTerm] = React.useState(); - const [lines, setLines] = React.useState(40); - const wsRef = useRef(null); // Ref to hold WebSocket instance +type TimeFilter = "all" | "1h" | "6h" | "24h" | "168h" | "720h"; +type TypeFilter = "all" | "error" | "warning" | "success" | "info" | "debug"; - useEffect(() => { - // if (containerId === "select-a-container") { - // return; - // } - const container = document.getElementById(id); - if (container) { - container.innerHTML = ""; - } +export const DockerLogsId: React.FC = ({ containerId, serverId }) => { + const { data } = api.docker.getConfig.useQuery( + { + containerId, + serverId: serverId ?? undefined, + }, + { + enabled: !!containerId, + } + ); - if (wsRef.current) { - if (wsRef.current.readyState === WebSocket.OPEN) { - wsRef.current.close(); - } - wsRef.current = null; - } - const termi = new Terminal({ - cursorBlink: true, - cols: 80, - rows: 30, - lineHeight: 1.25, - fontWeight: 400, - fontSize: 14, - fontFamily: - 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + 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(""); - convertEol: true, - theme: { - cursor: "transparent", - background: "rgba(0, 0, 0, 0)", - }, - }); + const [since, setSince] = React.useState("all"); + const [typeFilter, setTypeFilter] = React.useState("all"); + const scrollRef = useRef(null); + const [isLoading, setIsLoading] = React.useState(false); - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const scrollToBottom = () => { + if (autoScroll && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }; - const wsUrl = `${protocol}//${window.location.host}/docker-container-logs?containerId=${containerId}&tail=${lines}${serverId ? `&serverId=${serverId}` : ""}`; - const ws = new WebSocket(wsUrl); - wsRef.current = ws; - const fitAddon = new FitAddon(); - termi.loadAddon(fitAddon); - // @ts-ignore - termi.open(container); - fitAddon.fit(); - termi.focus(); - setTerm(termi); + const handleScroll = () => { + if (!scrollRef.current) return; - ws.onerror = (error) => { - console.error("WebSocket error: ", error); - }; + const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; + const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10; + setAutoScroll(isAtBottom); + }; - ws.onmessage = (e) => { - termi.write(e.data); - }; + const handleSearch = (e: React.ChangeEvent) => { + setSearch(e.target.value || ""); + }; - ws.onclose = (e) => { - console.log(e.reason); + const handleLines = (e: React.ChangeEvent) => { + setRawLogs(""); + setFilteredLogs([]); + setLines(Number(e.target.value) || 1); + }; - termi.write(`Connection closed!\nReason: ${e.reason}\n`); - wsRef.current = null; - }; - return () => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - ws.close(); - wsRef.current = null; - } - }; - }, [lines, containerId]); + const handleSince = (value: TimeFilter) => { + setRawLogs(""); + setFilteredLogs([]); + setSince(value); + }; - useEffect(() => { - term?.clear(); - }, [lines, term]); + const handleTypeFilter = (value: TypeFilter) => { + setTypeFilter(value); + }; - return ( -
-
- - { - setLines(Number(e.target.value) || 1); - }} - /> -
+ 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, + }); + + 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 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.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.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]); + + 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 handleFilter = (logs: LogLine[]) => { + return logs.filter((log) => { + const logType = getLogType(log.message).type; + + const matchesType = typeFilter === "all" || logType === typeFilter; + + return matchesType; + }); + }; + + useEffect(() => { + setRawLogs(""); + setFilteredLogs([]); + }, [containerId]); + + useEffect(() => { + const logs = parseLogs(rawLogs); + const filtered = handleFilter(logs); + setFilteredLogs(filtered); + }, [rawLogs, search, lines, since, typeFilter]); + + useEffect(() => { + scrollToBottom(); + + if (autoScroll && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [filteredLogs, autoScroll]); + + return ( +
+
+
+
+
+ + + + + + + +
+ + +
+
+ {filteredLogs.length > 0 ? ( + filteredLogs.map((filteredLog: LogLine, index: number) => ( + + )) + ) : isLoading ? ( +
+ +
+ ) : ( +
+ No logs found +
+ )} +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/apps/dokploy/components/dashboard/docker/logs/show-docker-modal-logs.tsx b/apps/dokploy/components/dashboard/docker/logs/show-docker-modal-logs.tsx index c3d38d98..f8531d77 100644 --- a/apps/dokploy/components/dashboard/docker/logs/show-docker-modal-logs.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/show-docker-modal-logs.tsx @@ -46,11 +46,7 @@ export const ShowDockerModalLogs = ({ View the logs for {containerId}
- +
diff --git a/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx b/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx new file mode 100644 index 00000000..cdbbb2c8 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx @@ -0,0 +1,111 @@ +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipPortal, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { escapeRegExp } from "lodash"; +import React from "react"; +import { type LogLine, getLogType } from "./utils"; + +interface LogLineProps { + log: LogLine; + noTimestamp?: boolean; + searchTerm?: string; +} + +export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) { + const { timestamp, message, rawTimestamp } = log; + const { type, variant, color } = getLogType(message); + + const formattedTime = timestamp + ? timestamp.toLocaleString([], { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + year: "2-digit", + second: "2-digit", + }) + : "--- 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} + + ) : ( + part + ), + ); + }; + + const tooltip = (color: string, timestamp: string | null) => { + const square = ( +
+ ); + return timestamp ? ( + + + {square} + + +

+

{timestamp}
+

+
+
+
+
+ ) : ( + square + ); + }; + + return ( +
+ {" "} +
+ {/* Icon to expand the log item maybe implement a colapsible later */} + {/* */} + {tooltip(color, rawTimestamp)} + {!noTimestamp && ( + + {formattedTime} + + )} + + + {type} + +
+ + {searchTerm ? highlightMessage(message, searchTerm) : message} + +
+ ); +} diff --git a/apps/dokploy/components/dashboard/docker/logs/utils.ts b/apps/dokploy/components/dashboard/docker/logs/utils.ts new file mode 100644 index 00000000..409c6989 --- /dev/null +++ b/apps/dokploy/components/dashboard/docker/logs/utils.ts @@ -0,0 +1,148 @@ +export type LogType = "error" | "warning" | "success" | "info" | "debug"; +export type LogVariant = "red" | "yellow" | "green" | "blue" | "orange"; + +export interface LogLine { + rawTimestamp: string | null; + timestamp: Date | null; + message: string; +} + +interface LogStyle { + type: LogType; + variant: LogVariant; + color: string; +} + +const LOG_STYLES: Record = { + error: { + type: "error", + variant: "red", + color: "bg-red-500/40", + }, + warning: { + type: "warning", + variant: "orange", + color: "bg-orange-500/40", + }, + debug: { + type: "debug", + variant: "yellow", + color: "bg-yellow-500/40", + }, + success: { + type: "success", + variant: "green", + color: "bg-green-500/40", + }, + info: { + type: "info", + variant: "blue", + color: "bg-blue-600/40", + }, +} as const; + +export function parseLogs(logString: string): LogLine[] { + // Regex to match the log line format + // Exemple of return : + // 1 2024-12-10T10:00:00.000Z The server is running on port 8080 + // Should return : + // { timestamp: new Date("2024-12-10T10:00:00.000Z"), + // message: "The server is running on port 8080" } + const logRegex = + /^(?:(\d+)\s+)?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC)?\s*(.*)$/; + + return logString + .split("\n") + .map((line) => line.trim()) + .filter((line) => line !== "") + .map((line) => { + const match = line.match(logRegex); + if (!match) return null; + + const [, , timestamp, message] = match; + + if (!message?.trim()) return null; + + // Delete other timestamps and keep only the one from --timestamps + const cleanedMessage = message + ?.replace( + /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC/g, + "", + ) + .trim(); + + return { + rawTimestamp: timestamp ?? null, + timestamp: timestamp ? new Date(timestamp.replace(" UTC", "Z")) : null, + message: cleanedMessage, + }; + }) + .filter((log) => log !== null); +} + +// Detect log type based on message content +export const getLogType = (message: string): LogStyle => { + const lowerMessage = message.toLowerCase(); + + if ( + /(?:^|\s)(?:info|inf|information):?\s/i.test(lowerMessage) || + /\[(?:info|information)\]/i.test(lowerMessage) || + /\b(?:status|state|current|progress)\b:?\s/i.test(lowerMessage) || + /\b(?:processing|executing|performing)\b/i.test(lowerMessage) + ) { + return LOG_STYLES.info; + } + + if ( + /(?:^|\s)(?:error|err):?\s/i.test(lowerMessage) || + /\b(?:exception|failed|failure)\b/i.test(lowerMessage) || + /(?:stack\s?trace):\s*$/i.test(lowerMessage) || + /^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(lowerMessage) || + /\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(lowerMessage) || + /Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(lowerMessage) || + /\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(lowerMessage) || + /\[(?:error|err|fatal)\]/i.test(lowerMessage) || + /\b(?:crash|critical|fatal)\b/i.test(lowerMessage) || + /\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(lowerMessage) + ) { + return LOG_STYLES.error; + } + + if ( + /(?:^|\s)(?:warning|warn):?\s/i.test(lowerMessage) || + /\[(?:warn(?:ing)?|attention)\]/i.test(lowerMessage) || + /(?:deprecated|obsolete)\s+(?:since|in|as\s+of)/i.test(lowerMessage) || + /\b(?:caution|attention|notice):\s/i.test(lowerMessage) || + /(?:might|may|could)\s+(?:not|cause|lead\s+to)/i.test(lowerMessage) || + /(?:!+\s*(?:warning|caution|attention)\s*!+)/i.test(lowerMessage) || + /\b(?:deprecated|obsolete)\b/i.test(lowerMessage) || + /\b(?:unstable|experimental)\b/i.test(lowerMessage) + ) { + return LOG_STYLES.warning; + } + + if ( + /(?:successfully|complete[d]?)\s+(?:initialized|started|completed|created|done|deployed)/i.test( + lowerMessage, + ) || + /\[(?:success|ok|done)\]/i.test(lowerMessage) || + /(?:listening|running)\s+(?:on|at)\s+(?:port\s+)?\d+/i.test(lowerMessage) || + /(?:connected|established|ready)\s+(?:to|for|on)/i.test(lowerMessage) || + /\b(?:loaded|mounted|initialized)\s+successfully\b/i.test(lowerMessage) || + /✓|√|✅|\[ok\]|done!/i.test(lowerMessage) || + /\b(?:success(?:ful)?|completed|ready)\b/i.test(lowerMessage) || + /\b(?:started|starting|active)\b/i.test(lowerMessage) + ) { + return LOG_STYLES.success; + } + + 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) + ) { + return LOG_STYLES.debug; + } + + return LOG_STYLES.info; +}; diff --git a/apps/dokploy/components/dashboard/settings/web-server/show-modal-logs.tsx b/apps/dokploy/components/dashboard/settings/web-server/show-modal-logs.tsx index 2693f79c..92401dc3 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/show-modal-logs.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/show-modal-logs.tsx @@ -91,11 +91,7 @@ export const ShowModalLogs = ({ appName, children, serverId }: Props) => { - +
diff --git a/apps/dokploy/components/ui/badge.tsx b/apps/dokploy/components/ui/badge.tsx index f38976c0..911b0071 100644 --- a/apps/dokploy/components/ui/badge.tsx +++ b/apps/dokploy/components/ui/badge.tsx @@ -14,6 +14,16 @@ 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", + 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", + 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", + 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", + 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/tooltip.tsx b/apps/dokploy/components/ui/tooltip.tsx index c533f855..f715fd31 100644 --- a/apps/dokploy/components/ui/tooltip.tsx +++ b/apps/dokploy/components/ui/tooltip.tsx @@ -9,6 +9,8 @@ const Tooltip = TooltipPrimitive.Root; const TooltipTrigger = TooltipPrimitive.Trigger; +const TooltipPortal = TooltipPrimitive.Portal; + const TooltipContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -25,4 +27,10 @@ const TooltipContent = React.forwardRef< )); TooltipContent.displayName = TooltipPrimitive.Content.displayName; -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; +export { + Tooltip, + TooltipTrigger, + TooltipContent, + TooltipProvider, + TooltipPortal, +}; diff --git a/apps/dokploy/server/wss/docker-container-logs.ts b/apps/dokploy/server/wss/docker-container-logs.ts index 1493f698..0c1e66a4 100644 --- a/apps/dokploy/server/wss/docker-container-logs.ts +++ b/apps/dokploy/server/wss/docker-container-logs.ts @@ -31,6 +31,8 @@ export const setupDockerContainerLogsWebSocketServer = ( const url = new URL(req.url || "", `http://${req.headers.host}`); const containerId = url.searchParams.get("containerId"); const tail = url.searchParams.get("tail"); + const search = url.searchParams.get("search"); + const since = url.searchParams.get("since"); const serverId = url.searchParams.get("serverId"); const { user, session } = await validateWebSocketRequest(req); @@ -51,9 +53,13 @@ export const setupDockerContainerLogsWebSocketServer = ( const client = new Client(); client .once("ready", () => { - const command = ` - bash -c "docker container logs --tail ${tail} --follow ${containerId}" - `; + const baseCommand = `docker container logs --timestamps --tail ${tail} ${ + since === "all" ? "" : `--since ${since}` + } --follow ${containerId}`; + const escapedSearch = search ? search.replace(/'/g, "'\\''") : ""; + const command = search + ? `${baseCommand} 2>&1 | grep --line-buffered -iF "${escapedSearch}"` + : baseCommand; client.exec(command, (err, stream) => { if (err) { console.error("Execution error:", err); @@ -91,21 +97,20 @@ export const setupDockerContainerLogsWebSocketServer = ( }); } else { const shell = getShell(); - const ptyProcess = spawn( - shell, - [ - "-c", - `docker container logs --tail ${tail} --follow ${containerId}`, - ], - { - name: "xterm-256color", - cwd: process.env.HOME, - env: process.env, - encoding: "utf8", - cols: 80, - rows: 30, - }, - ); + const baseCommand = `docker container logs --timestamps --tail ${tail} ${ + since === "all" ? "" : `--since ${since}` + } --follow ${containerId}`; + const command = search + ? `${baseCommand} 2>&1 | grep -iF '${search}'` + : baseCommand; + const ptyProcess = spawn(shell, ["-c", command], { + name: "xterm-256color", + cwd: process.env.HOME, + env: process.env, + encoding: "utf8", + cols: 80, + rows: 30, + }); ptyProcess.onData((data) => { ws.send(data); diff --git a/apps/dokploy/styles/globals.css b/apps/dokploy/styles/globals.css index f7e2a71f..ab6084e1 100644 --- a/apps/dokploy/styles/globals.css +++ b/apps/dokploy/styles/globals.css @@ -173,3 +173,29 @@ padding-top: 1rem !important; } } + +/* Docker Logs Scrollbar */ +@layer utilities { + .custom-logs-scrollbar { + scrollbar-width: thin; + scrollbar-color: hsl(var(--muted-foreground)) transparent; + } + + .custom-logs-scrollbar::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + .custom-logs-scrollbar::-webkit-scrollbar-track { + background: transparent; + } + + .custom-logs-scrollbar::-webkit-scrollbar-thumb { + background-color: hsl(var(--muted-foreground) / 0.3); + border-radius: 20px; + } + + .custom-logs-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: hsl(var(--muted-foreground) / 0.5); + } +}