feat(logs): new logs style and system

This commit is contained in:
190km
2024-12-11 00:20:22 +01:00
parent 0b3e15aabc
commit 00f9e262a9
11 changed files with 479 additions and 104 deletions

View File

@@ -90,7 +90,6 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
</Select> </Select>
<DockerLogs <DockerLogs
serverId={serverId || ""} serverId={serverId || ""}
id="terminal"
containerId={containerId || "select-a-container"} containerId={containerId || "select-a-container"}
/> />
</CardContent> </CardContent>

View File

@@ -96,7 +96,6 @@ export const ShowDockerLogsCompose = ({
</Select> </Select>
<DockerLogs <DockerLogs
serverId={serverId || ""} serverId={serverId || ""}
id="terminal"
containerId={containerId || "select-a-container"} containerId={containerId || "select-a-container"}
/> />
</CardContent> </CardContent>

View File

@@ -1,115 +1,247 @@
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button";
import { Terminal } from "@xterm/xterm";
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { FitAddon } from "xterm-addon-fit"; import { getLogType, LogLine, parseLogs } from "./utils";
import "@xterm/xterm/css/xterm.css"; import { TerminalLine } from "./terminal-line";
import { Download as DownloadIcon } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
interface Props { interface Props {
id: string; containerId: string;
containerId: string; serverId?: string | null;
serverId?: string | null;
} }
export const DockerLogsId: React.FC<Props> = ({ type TimeFilter = "all" | "1h" | "6h" | "24h" | "168h" | "720h";
id, type TypeFilter = "all" | "error" | "warning" | "success" | "info";
containerId,
serverId,
}) => {
const [term, setTerm] = React.useState<Terminal>();
const [lines, setLines] = React.useState<number>(40);
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
useEffect(() => { export const DockerLogsId: React.FC<Props> = ({ containerId, serverId }) => {
// if (containerId === "select-a-container") { const [rawLogs, setRawLogs] = React.useState("");
// return; const [filteredLogs, setFilteredLogs] = React.useState<LogLine[]>([]);
// } const [autoScroll, setAutoScroll] = React.useState(true);
const container = document.getElementById(id); const [lines, setLines] = React.useState<number>(100);
if (container) { const [search, setSearch] = React.useState<string>("");
container.innerHTML = ""; const [since, setSince] = React.useState<TimeFilter>("all");
} const [typeFilter, setTypeFilter] = React.useState<TypeFilter>("all");
const scrollRef = useRef<HTMLDivElement>(null);
if (wsRef.current) { const scrollToBottom = () => {
if (wsRef.current.readyState === WebSocket.OPEN) { if (autoScroll && scrollRef.current) {
wsRef.current.close(); scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
} }
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',
convertEol: true, const handleScroll = () => {
theme: { if (!scrollRef.current) return;
cursor: "transparent",
background: "rgba(0, 0, 0, 0)",
},
});
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
setAutoScroll(isAtBottom);
};
const wsUrl = `${protocol}//${window.location.host}/docker-container-logs?containerId=${containerId}&tail=${lines}${serverId ? `&serverId=${serverId}` : ""}`; const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const ws = new WebSocket(wsUrl); setRawLogs("");
wsRef.current = ws; setFilteredLogs([]);
const fitAddon = new FitAddon(); setSearch(e.target.value || "");
termi.loadAddon(fitAddon); };
// @ts-ignore
termi.open(container);
fitAddon.fit();
termi.focus();
setTerm(termi);
ws.onerror = (error) => { const handleLines = (e: React.ChangeEvent<HTMLInputElement>) => {
console.error("WebSocket error: ", error); setRawLogs("");
}; setFilteredLogs([]);
setLines(Number(e.target.value) || 1);
};
ws.onmessage = (e) => { const handleSince = (value: TimeFilter) => {
termi.write(e.data); setRawLogs("");
}; setFilteredLogs([]);
setSince(value);
};
ws.onclose = (e) => { const handleTypeFilter = (value: TypeFilter) => {
console.log(e.reason); setTypeFilter(value);
};
termi.write(`Connection closed!\nReason: ${e.reason}\n`); useEffect(() => {
wsRef.current = null; setRawLogs("");
}; setFilteredLogs([]);
return () => { }, [containerId]);
if (wsRef.current?.readyState === WebSocket.OPEN) {
ws.close();
wsRef.current = null;
}
};
}, [lines, containerId]);
useEffect(() => { useEffect(() => {
term?.clear(); if (!containerId) return;
}, [lines, term]);
return ( const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
<div className="flex flex-col gap-4"> const wsUrl = `${protocol}//${
<div className="flex flex-col gap-2"> window.location.host
<Label> }/docker-container-logs?containerId=${containerId}&tail=${lines}&since=${since}&search=${search}${
<span>Number of lines to show</span> serverId ? `&serverId=${serverId}` : ""
</Label> }`;
<Input console.log("Connecting to WebSocket:", wsUrl);
type="text" const ws = new WebSocket(wsUrl);
placeholder="Number of lines to show (Defaults to 35)"
value={lines}
onChange={(e) => {
setLines(Number(e.target.value) || 1);
}}
/>
</div>
<div className="w-full h-full rounded-lg p-2 bg-[#19191A]"> ws.onopen = () => {
<div id={id} /> console.log("WebSocket connected");
</div> };
</div>
); ws.onmessage = (e) => {
// console.log("Received message:", e.data);
setRawLogs((prev) => prev + e.data);
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
ws.onclose = (e) => {
console.log("WebSocket closed:", e.reason);
setRawLogs(
(prev) =>
prev +
`Connection closed!\nReason: ${
e.reason || "WebSocket was closed try to refresh"
}\n`
);
};
return () => {
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");
a.href = url;
a.download = `dokploy-logs-${new Date().toISOString()}.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 (
<div className="flex flex-col gap-4">
<div className="shadow-md rounded-lg overflow-hidden">
<div className="space-y-4">
<div className="flex flex-wrap justify-between items-start sm:items-center gap-4">
<div className="flex flex-wrap gap-4">
<Input
type="text"
placeholder="Number of lines to show"
value={lines}
onChange={handleLines}
className="inline-flex h-9 text-sm text-white placeholder-gray-400 w-full sm:w-auto"
/>
<Input
type="text"
placeholder="Search logs..."
value={search}
onChange={handleSearch}
className="inline-flex h-9 text-sm text-white placeholder-gray-400 w-full sm:w-auto"
/>
<Select value={since} onValueChange={handleSince}>
<SelectTrigger className="w-[180px] h-9">
<SelectValue placeholder="Time filter" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1h">Last 1 hour</SelectItem>
<SelectItem value="6h">Last 6 hours</SelectItem>
<SelectItem value="24h">Last 24 hours</SelectItem>
<SelectItem value="168h">Last 7 days</SelectItem>
<SelectItem value="720h">Last 30 days</SelectItem>
<SelectItem value="all">All time</SelectItem>
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={handleTypeFilter}>
<SelectTrigger className="w-[180px] h-9">
<SelectValue placeholder="Type filter" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
<Badge variant="blank">All</Badge>
</SelectItem>
<SelectItem value="error">
<Badge variant="red">Error</Badge>
</SelectItem>
<SelectItem value="warning">
<Badge variant="yellow">Warning</Badge>
</SelectItem>
<SelectItem value="success">
<Badge variant="green">Success</Badge>
</SelectItem>
<SelectItem value="info">
<Badge variant="blue">Info</Badge>
</SelectItem>
</SelectContent>
</Select>
</div>
<Button
variant="outline"
size="sm"
className="h-9"
onClick={handleDownload}
>
<DownloadIcon className="mr-2 h-4 w-4" />
Download logs
</Button>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#d4d4d4] dark:bg-[#050506] rounded custom-logs-scrollbar"
>
{filteredLogs.map((filteredLog: LogLine, index: number) => (
<TerminalLine key={index} log={filteredLog} searchTerm={search} />
))}
</div>
</div>
</div>
</div>
);
}; };

View File

@@ -47,7 +47,6 @@ export const ShowDockerModalLogs = ({
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-4 pt-2.5"> <div className="flex flex-col gap-4 pt-2.5">
<DockerLogsId <DockerLogsId
id="terminal"
containerId={containerId || ""} containerId={containerId || ""}
serverId={serverId} serverId={serverId}
/> />

View File

@@ -0,0 +1,73 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { getLogType, type LogLine } from "./utils";
import React from "react";
interface LogLineProps {
log: LogLine;
searchTerm?: string;
}
export function TerminalLine({ log, searchTerm }: LogLineProps) {
const { timestamp, message } = log;
const { type, variant, color } = getLogType(message);
const formattedTime = timestamp
? timestamp.toLocaleString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})
: "--- No time found ---";
const highlightMessage = (text: string, term: string) => {
if (!term) return text;
const parts = text.split(new RegExp(`(${term})`, "gi"));
return parts.map((part, index) =>
part.toLowerCase() === term.toLowerCase() ? (
<span key={index} className="bg-yellow-200 dark:bg-yellow-900">
{part}
</span>
) : (
part
)
);
};
return (
<div
className={cn(
"font-mono text-xs flex flex-row gap-3 py-2 sm:py-0.5 group",
type === "error"
? "bg-red-500/10 hover:bg-red-500/15"
: type === "warning"
? "bg-yellow-500/10 hover:bg-yellow-500/15"
: "hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
)}
>
{" "}
<div className="flex items-start gap-x-2">
{/* Icon to expand the log item maybe implement a colapsible later */}
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
<div className={cn("w-2 h-full flex-shrink-0 rounded-[3px]", color)} />
<span className="select-none pl-2 text-muted-foreground w-full sm:w-40 flex-shrink-0">
{formattedTime}
</span>
<Badge
variant={variant}
className="w-14 justify-center text-[10px] px-1 py-0"
>
{type}
</Badge>
</div>
<span className="dark:text-gray-200 text-foreground ">
{searchTerm ? highlightMessage(message, searchTerm) : message}
</span>
</div>
);
}

View File

@@ -0,0 +1,132 @@
export type LogType = "error" | "warning" | "success" | "info";
export type LogVariant = "red" | "yellow" | "green" | "blue";
export interface LogLine {
timestamp: Date | null;
message: string;
}
interface LogStyle {
type: LogType;
variant: LogVariant;
color: string;
}
const LOG_STYLES: Record<LogType, LogStyle> = {
error: {
type: "error",
variant: "red",
color: "bg-red-500/40",
},
warning: {
type: "warning",
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 {
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)(?: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|done)/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|running|active)\b/i.test(lowerMessage)
) {
return LOG_STYLES.success;
}
if (
/(?:^|\s)(?:info|inf):?\s/i.test(lowerMessage) ||
/\[(info|log|debug|trace|server|db|api)\]/i.test(lowerMessage) ||
/\b(?:version|config|start|import|load)\b:?/i.test(lowerMessage)
) {
return LOG_STYLES.info;
}
return LOG_STYLES.info;
};

View File

@@ -92,7 +92,6 @@ export const ShowModalLogs = ({ appName, children, serverId }: Props) => {
</SelectContent> </SelectContent>
</Select> </Select>
<DockerLogsId <DockerLogsId
id="terminal"
containerId={containerId || ""} containerId={containerId || ""}
serverId={serverId} serverId={serverId}
/> />

View File

@@ -14,6 +14,18 @@ const badgeVariants = cva(
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", "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", outline: "text-foreground",
}, },
}, },

View File

@@ -31,6 +31,8 @@ export const setupDockerContainerLogsWebSocketServer = (
const url = new URL(req.url || "", `http://${req.headers.host}`); const url = new URL(req.url || "", `http://${req.headers.host}`);
const containerId = url.searchParams.get("containerId"); const containerId = url.searchParams.get("containerId");
const tail = url.searchParams.get("tail"); 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 serverId = url.searchParams.get("serverId");
const { user, session } = await validateWebSocketRequest(req); const { user, session } = await validateWebSocketRequest(req);
@@ -52,7 +54,7 @@ export const setupDockerContainerLogsWebSocketServer = (
client client
.once("ready", () => { .once("ready", () => {
const command = ` const command = `
bash -c "docker container logs --tail ${tail} --follow ${containerId}" bash -c "docker container logs --timestamps --tail ${tail} ${since === "all" ? "" : `--since ${since}`} --follow ${containerId} | grep -i '${search}'"
`; `;
client.exec(command, (err, stream) => { client.exec(command, (err, stream) => {
if (err) { if (err) {
@@ -95,7 +97,7 @@ export const setupDockerContainerLogsWebSocketServer = (
shell, shell,
[ [
"-c", "-c",
`docker container logs --tail ${tail} --follow ${containerId}`, `docker container logs --timestamps --tail ${tail} ${since === "all" ? "" : `--since ${since}`} --follow ${containerId} | grep -i '${search}'`,
], ],
{ {
name: "xterm-256color", name: "xterm-256color",

View File

@@ -173,3 +173,29 @@
padding-top: 1rem !important; 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);
}
}

View File

@@ -32,6 +32,8 @@ export const setupDockerContainerLogsWebSocketServer = (
const url = new URL(req.url || "", `http://${req.headers.host}`); const url = new URL(req.url || "", `http://${req.headers.host}`);
const containerId = url.searchParams.get("containerId"); const containerId = url.searchParams.get("containerId");
const tail = url.searchParams.get("tail"); const tail = url.searchParams.get("tail");
const since = url.searchParams.get("since");
const search = url.searchParams.get("search");
const serverId = url.searchParams.get("serverId"); const serverId = url.searchParams.get("serverId");
const { user, session } = await validateWebSocketRequest(req); const { user, session } = await validateWebSocketRequest(req);
@@ -54,7 +56,7 @@ export const setupDockerContainerLogsWebSocketServer = (
client client
.once("ready", () => { .once("ready", () => {
const command = ` const command = `
bash -c "docker container logs --tail ${tail} --follow ${containerId}" bash -c "docker container logs --timestamps --tail ${tail} ${since === "all" ? "" : `--since ${since}`} --follow ${containerId} | grep -i '${search}'"
`; `;
client.exec(command, (err, stream) => { client.exec(command, (err, stream) => {
if (err) { if (err) {
@@ -89,7 +91,7 @@ export const setupDockerContainerLogsWebSocketServer = (
shell, shell,
[ [
"-c", "-c",
`docker container logs --tail ${tail} --follow ${containerId}`, `docker container logs --timestamps --tail ${tail} ${since === "all" ? "" : `--since ${since}`} --follow ${containerId} | grep -i '${search}'`,
], ],
{ {
name: "xterm-256color", name: "xterm-256color",