diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx index 45408fe99..d3bc01049 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx @@ -1,6 +1,5 @@ "use client"; -import { useEffect, useState } from "react"; import type { MantineColor } from "@mantine/core"; import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core"; import { @@ -19,7 +18,7 @@ import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { humanFileSize, useTimeAgo } from "@homarr/common"; import type { ContainerState } from "@homarr/docker"; -import { containerStateColorMap } from "@homarr/docker/shared"; +import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared"; import { useModalAction } from "@homarr/modals"; import { AddDockerAppToHomarr, DockerLogsModal } from "@homarr/modals-collection"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; @@ -80,9 +79,10 @@ const createColumns = ( accessorKey: "ports", header: t("docker.field.ports.label"), Cell({ cell }) { - if (!cell?.row?.original?.ports?.length) return null; + const ports = cell.row.original.ports; + if (!ports || ports.length === 0) return null; return ( - port.PrivatePort.toString())} /> + port.PrivatePort.toString())} /> ); }, }, @@ -135,7 +135,6 @@ const createColumns = ( export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"]) { const t = useI18n(); const tDocker = useScopedI18n("docker"); - const [showStats, setShowStats] = useState(false); const { data } = clientApi.docker.getContainers.useQuery(undefined, { initialData, refetchOnMount: false, @@ -209,14 +208,6 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers" columns: createColumns(t), }); - useEffect(() => { - table.setColumnVisibility((prev) => ({ - ...prev, - cpuUsage: showStats, - memoryUsage: showStats, - })); - }, [showStats, table]); - return ( <> {tDocker("table.updated", { when: relativeTime })} @@ -334,21 +325,3 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => { ); }; -const memoryUsageColor = (number: number, state: string) => { - const mbUsage = number / 1024 / 1024; - if (mbUsage === 0 && state !== "running") return "red"; - if (mbUsage < 128) return "green"; - if (mbUsage < 256) return "yellow"; - if (mbUsage < 512) return "orange"; - return "red"; -}; - -const cpuUsageColor = (number: number, state: string) => { - if (number === 0 && state !== "running") return "red"; - if (number < 40) return "green"; - if (number < 60) return "yellow"; - if (number < 90) return "orange"; - return "red"; -}; - -const safeValue = (value?: number, fallback = 0) => (value !== undefined && !isNaN(value) ? value : fallback); diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index 013c8e386..213789023 100644 --- a/packages/api/src/router/docker/docker-router.ts +++ b/packages/api/src/router/docker/docker-router.ts @@ -4,7 +4,11 @@ import { z } from "zod/v4"; import type { Container, ContainerState, Docker, Port } from "@homarr/docker"; import { DockerSingleton } from "@homarr/docker"; -import { dockerContainersRequestHandler, getContainerLogsAsync } from "@homarr/request-handler/docker"; +import { + dockerContainersRequestHandler, + getContainerLogsAsync, + streamContainerLogsAsync, +} from "@homarr/request-handler/docker"; import { dockerMiddleware } from "../../middlewares/docker"; import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc"; @@ -127,6 +131,61 @@ export const dockerRouter = createTRPCRouter({ logs, }; }), + subscribeLogs: permissionRequiredProcedure + .requiresPermission("admin") + .concat(dockerMiddleware()) + .input( + z.object({ + id: z.string(), + tail: z.number().min(1).max(1000).optional(), + }), + ) + .subscription(({ input }) => { + return observable((emit) => { + let cleanupFn: (() => void) | undefined; + let isSubscribed = true; + + const initializeStream = async () => { + try { + cleanupFn = await streamContainerLogsAsync( + input.id, + input.tail ?? 200, + (data) => { + if (isSubscribed) { + emit.next(data); + } + }, + (err) => { + if (isSubscribed) { + emit.error( + new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: err.message, + }), + ); + } + }, + ); + } catch (err) { + if (isSubscribed) { + emit.error( + new TRPCError({ + code: "NOT_FOUND", + message: err instanceof Error ? err.message : "Container not found", + }), + ); + } + } + }; + + void initializeStream(); + + return () => { + isSubscribed = false; + cleanupFn?.(); + }; + }); + }), }); const getContainerOrDefaultAsync = async (instance: Docker, id: string) => { diff --git a/packages/docker/src/shared.ts b/packages/docker/src/shared.ts index f50bd32c7..a7d9b79dc 100644 --- a/packages/docker/src/shared.ts +++ b/packages/docker/src/shared.ts @@ -11,3 +11,22 @@ export const containerStateColorMap = { removing: "pink", dead: "dark", } satisfies Record; + +export const memoryUsageColor = (number: number, state: string): MantineColor => { + const mbUsage = number / 1024 / 1024; + if (mbUsage === 0 && state !== "running") return "red"; + if (mbUsage < 128) return "green"; + if (mbUsage < 256) return "yellow"; + if (mbUsage < 512) return "orange"; + return "red"; +}; + +export const cpuUsageColor = (number: number, state: string): MantineColor => { + if (number === 0 && state !== "running") return "red"; + if (number < 40) return "green"; + if (number < 60) return "yellow"; + if (number < 90) return "orange"; + return "red"; +}; + +export const safeValue = (value?: number, fallback = 0): number => (value !== undefined && !isNaN(value) ? value : fallback); diff --git a/packages/modals-collection/src/docker/docker-logs-modal.tsx b/packages/modals-collection/src/docker/docker-logs-modal.tsx index 2cee0534c..d0924a5dc 100644 --- a/packages/modals-collection/src/docker/docker-logs-modal.tsx +++ b/packages/modals-collection/src/docker/docker-logs-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { Box, Group, Loader, ScrollArea, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; @@ -13,44 +13,99 @@ interface DockerLogsModalInnerProps { tail?: number; } +const SCROLL_THRESHOLD = 100; + +const isViewportAtBottom = (viewport: HTMLDivElement | null): boolean => { + if (!viewport) return false; + return viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < SCROLL_THRESHOLD; +}; + export const DockerLogsModal = createModal(({ innerProps }) => { const t = useI18n(); const viewportRef = useRef(null); - const didInitialScrollRef = useRef(false); + const [logs, setLogs] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const shouldScrollRef = useRef(true); + const hasInitializedRef = useRef(false); - const { data, isPending, isError } = clientApi.docker.logs.useQuery( - { id: innerProps.id, tail: innerProps.tail }, + // Get initial logs + const { data: initialData } = clientApi.docker.logs.useQuery( + { id: innerProps.id, tail: innerProps.tail ?? 200 }, { refetchOnWindowFocus: false, retry: 0, - refetchInterval: 1000, - refetchIntervalInBackground: true, }, ); + // Maximum log size to prevent memory issues (500KB) + const MAX_LOG_SIZE = 500 * 1024; + + // Subscribe to streaming logs + clientApi.docker.subscribeLogs.useSubscription( + { id: innerProps.id, tail: innerProps.tail ?? 200 }, + { + onData(data) { + setLogs((prev) => { + const newLogs = prev + data; + // Truncate from the beginning if logs exceed maximum size + if (newLogs.length > MAX_LOG_SIZE) { + const truncated = newLogs.slice(-MAX_LOG_SIZE); + // Try to start from a newline to avoid partial lines + const firstNewline = truncated.indexOf("\n"); + return firstNewline > 0 ? truncated.slice(firstNewline + 1) : truncated; + } + return newLogs; + }); + setIsLoading(false); + }, + onError(err) { + setError(err.message); + setIsLoading(false); + }, + }, + ); + + // Initialize with static logs (only once) useEffect(() => { - if (didInitialScrollRef.current) return; - if (!data?.logs) return; + if (initialData?.logs && !hasInitializedRef.current) { + setLogs(initialData.logs); + setIsLoading(false); + hasInitializedRef.current = true; + } + }, [initialData]); + + // Auto-scroll to bottom when new logs arrive + useEffect(() => { + if (!shouldScrollRef.current) return; const viewport = viewportRef.current; if (!viewport) return; - viewport.scrollTop = viewport.scrollHeight; - didInitialScrollRef.current = true; - }, [data?.logs]); + + if (isViewportAtBottom(viewport)) { + viewport.scrollTop = viewport.scrollHeight; + } + }, [logs]); + + // Handle manual scroll to determine auto-scroll behavior + const handleScroll = () => { + const viewport = viewportRef.current; + shouldScrollRef.current = isViewportAtBottom(viewport); + }; return ( - {isPending ? ( + {isLoading ? ( - ) : isError ? ( + ) : error ? ( - Failed to load logs + {error} ) : ( - - {data?.logs ? ( + + {logs ? (
(({ innerPr
                   overflowWrap: "anywhere",
                 }}
               >
-                {data.logs}
+                {logs}
               
) : ( diff --git a/packages/request-handler/src/docker.ts b/packages/request-handler/src/docker.ts index f6f109275..c73eaf014 100644 --- a/packages/request-handler/src/docker.ts +++ b/packages/request-handler/src/docker.ts @@ -57,6 +57,70 @@ export const getContainerLogsAsync = async (id: string, tail = 200) => { return decodeDockerLogs(rawLogs); }; +export const streamContainerLogsAsync = async ( + id: string, + tail: number, + onData: (data: string) => void, + onError: (err: Error) => void, +) => { + const container = await findContainerByIdAsync(id); + if (!container) { + onError(new Error("Container not found")); + return () => undefined; + } + + const stream = await container.logs({ + tail, + stdout: true, + stderr: true, + follow: true, + }); + + // Docker uses a multiplexed stream format for logs where each message has an 8-byte header: + // - 1 byte: stream type (stdout/stderr) + // - 3 bytes: padding + // - 4 bytes: payload length (big-endian) + // We need to process the stream in chunks and extract complete messages from the buffer. + const DOCKER_STREAM_HEADER_SIZE = 8; + const MAX_MESSAGE_SIZE = 1024 * 1024; // 1MB maximum message size for safety + let buffer = Buffer.alloc(0); + + const processChunk = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + + while (buffer.length >= DOCKER_STREAM_HEADER_SIZE) { + const length = buffer.readUInt32BE(4); + + // Validate message size to prevent memory exhaustion from corrupted data + if (length > MAX_MESSAGE_SIZE) { + onError(new Error(`Docker log message size (${length} bytes) exceeds maximum allowed (${MAX_MESSAGE_SIZE} bytes)`)); + return; + } + + const fullMessageSize = DOCKER_STREAM_HEADER_SIZE + length; + + if (buffer.length < fullMessageSize) { + // Wait for more data + break; + } + + const payload = buffer.subarray(DOCKER_STREAM_HEADER_SIZE, fullMessageSize); + onData(payload.toString("utf-8")); + + buffer = buffer.subarray(fullMessageSize); + } + }; + + stream.on("data", processChunk); + stream.on("error", onError); + + return () => { + stream.removeListener("data", processChunk); + stream.removeListener("error", onError); + stream.destroy(); + }; +}; + const decodeDockerLogs = (logs: Buffer | string) => { if (typeof logs === "string") { return logs; diff --git a/packages/widgets/src/docker/component.tsx b/packages/widgets/src/docker/component.tsx index 869b76f0b..a8d0b6f3c 100644 --- a/packages/widgets/src/docker/component.tsx +++ b/packages/widgets/src/docker/component.tsx @@ -11,7 +11,7 @@ import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { humanFileSize, useTimeAgo } from "@homarr/common"; import type { ContainerState } from "@homarr/docker"; -import { containerStateColorMap } from "@homarr/docker/shared"; +import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; @@ -28,25 +28,6 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => { ); }; -const memoryUsageColor = (number: number, state: string) => { - const mbUsage = number / 1024 / 1024; - if (mbUsage === 0 && state !== "running") return "red"; - if (mbUsage < 128) return "green"; - if (mbUsage < 256) return "yellow"; - if (mbUsage < 512) return "orange"; - return "red"; -}; - -const cpuUsageColor = (number: number, state: string) => { - if (number === 0 && state !== "running") return "red"; - if (number < 40) return "green"; - if (number < 60) return "yellow"; - if (number < 90) return "orange"; - return "red"; -}; - -const safeValue = (value?: number, fallback = 0) => (value !== undefined && !isNaN(value) ? value : fallback); - const actionIconIconStyle: IconProps["style"] = { height: "var(--ai-icon-size)", width: "var(--ai-icon-size)",