From 2890f04661177c20cf5e53002b2b9c6f23133140 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 6 Jan 2026 18:55:34 +0100 Subject: [PATCH 01/15] feat(docker): add container logs query --- .../api/src/router/docker/docker-router.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index 6a0ea49a0..013c8e386 100644 --- a/packages/api/src/router/docker/docker-router.ts +++ b/packages/api/src/router/docker/docker-router.ts @@ -4,7 +4,7 @@ import { z } from "zod/v4"; import type { Container, ContainerState, Docker, Port } from "@homarr/docker"; import { DockerSingleton } from "@homarr/docker"; -import { dockerContainersRequestHandler } from "@homarr/request-handler/docker"; +import { dockerContainersRequestHandler, getContainerLogsAsync } from "@homarr/request-handler/docker"; import { dockerMiddleware } from "../../middlewares/docker"; import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc"; @@ -104,6 +104,29 @@ export const dockerRouter = createTRPCRouter({ const innerHandler = dockerContainersRequestHandler.handler({}); await innerHandler.invalidateAsync(); }), + logs: permissionRequiredProcedure + .requiresPermission("admin") + .concat(dockerMiddleware()) + .input( + z.object({ + id: z.string(), + tail: z.number().min(1).max(1000).optional(), + }), + ) + .query(async ({ input }) => { + const logs = await getContainerLogsAsync(input.id, input.tail ?? 200); + + if (!logs) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Container not found", + }); + } + + return { + logs, + }; + }), }); const getContainerOrDefaultAsync = async (instance: Docker, id: string) => { From 749d00daee909ba9c20cb0eb80edd537bc6c1d9c Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 6 Jan 2026 18:55:42 +0100 Subject: [PATCH 02/15] feat(docker): decode and fetch container logs --- packages/request-handler/src/docker.ts | 69 +++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/packages/request-handler/src/docker.ts b/packages/request-handler/src/docker.ts index 0d2c02809..f6f109275 100644 --- a/packages/request-handler/src/docker.ts +++ b/packages/request-handler/src/docker.ts @@ -1,5 +1,5 @@ import dayjs from "dayjs"; -import type { ContainerInfo, ContainerStats } from "dockerode"; +import type { Container, ContainerInfo, ContainerStats } from "dockerode"; import { db, like, or } from "@homarr/db"; import { icons } from "@homarr/db/schema"; @@ -21,6 +21,73 @@ const dockerInstances = DockerSingleton.getInstances(); const extractImage = (container: ContainerInfo) => container.Image.split("/").at(-1)?.split(":").at(0) ?? ""; +const findContainerByIdAsync = async (id: string) => { + const containers = await Promise.all( + dockerInstances.map(async ({ instance }) => { + const container = instance.getContainer(id); + + return await new Promise((resolve) => { + container.inspect((err, data) => { + if (err || !data) { + resolve(null); + } else { + resolve(container); + } + }); + }); + }), + ); + + return containers.find((container) => container) ?? null; +}; + +export const getContainerLogsAsync = async (id: string, tail = 200) => { + const container = await findContainerByIdAsync(id); + if (!container) { + return null; + } + + const rawLogs = await container.logs({ + tail, + stdout: true, + stderr: true, + follow: false, + }); + + return decodeDockerLogs(rawLogs); +}; + +const decodeDockerLogs = (logs: Buffer | string) => { + if (typeof logs === "string") { + return logs; + } + + // Docker multiplexed stream: 1 byte stream type, 3 bytes padding, 4 bytes big-endian length, then payload + // Repeat until buffer consumed. + let cursor = 0; + const parts: string[] = []; + + while (cursor < logs.length) { + // Defensive: need at least 8 bytes header + if (cursor + 8 > logs.length) { + break; + } + + const length = logs.readUInt32BE(cursor + 4); + const start = cursor + 8; + const end = start + length; + if (end > logs.length) { + break; + } + + const payload = logs.subarray(start, end); + parts.push(payload.toString("utf-8")); + cursor = end; + } + + return parts.join(""); +}; + async function getContainersWithStatsAsync() { const containers = await Promise.all( dockerInstances.map(async ({ instance, host }) => { From ecaaba429dd4090b051650c4cad4e0239d8c1449 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 6 Jan 2026 18:55:51 +0100 Subject: [PATCH 03/15] feat(modals): add docker logs modal --- .../src/docker/docker-logs-modal.tsx | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 packages/modals-collection/src/docker/docker-logs-modal.tsx diff --git a/packages/modals-collection/src/docker/docker-logs-modal.tsx b/packages/modals-collection/src/docker/docker-logs-modal.tsx new file mode 100644 index 000000000..2cee0534c --- /dev/null +++ b/packages/modals-collection/src/docker/docker-logs-modal.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { Box, Group, Loader, ScrollArea, Stack, Text } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { createModal } from "@homarr/modals"; +import { useI18n } from "@homarr/translation/client"; + +interface DockerLogsModalInnerProps { + id: string; + name: string; + tail?: number; +} + +export const DockerLogsModal = createModal(({ innerProps }) => { + const t = useI18n(); + const viewportRef = useRef(null); + const didInitialScrollRef = useRef(false); + + const { data, isPending, isError } = clientApi.docker.logs.useQuery( + { id: innerProps.id, tail: innerProps.tail }, + { + refetchOnWindowFocus: false, + retry: 0, + refetchInterval: 1000, + refetchIntervalInBackground: true, + }, + ); + + useEffect(() => { + if (didInitialScrollRef.current) return; + if (!data?.logs) return; + const viewport = viewportRef.current; + if (!viewport) return; + viewport.scrollTop = viewport.scrollHeight; + didInitialScrollRef.current = true; + }, [data?.logs]); + + return ( + + + {isPending ? ( + + + + ) : isError ? ( + + Failed to load logs + + ) : ( + + {data?.logs ? ( +
+                {data.logs}
+              
+ ) : ( + + {t("common.state.empty")} + + )} +
+ )} +
+
+ ); +}).withOptions({ + size: "95%", + centered: true, + defaultTitle: (t) => t("docker.action.logs.label"), +}); + + From ac327caf5fca89a9ec7334586ca0a2c36ec84d29 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 6 Jan 2026 18:56:01 +0100 Subject: [PATCH 04/15] feat(modals): export docker logs modal --- packages/modals-collection/src/docker/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/modals-collection/src/docker/index.ts b/packages/modals-collection/src/docker/index.ts index 31b956245..bb32f7770 100644 --- a/packages/modals-collection/src/docker/index.ts +++ b/packages/modals-collection/src/docker/index.ts @@ -1 +1,2 @@ export { AddDockerAppToHomarrModal as AddDockerAppToHomarr } from "./add-docker-app-to-homarr"; +export { DockerLogsModal } from "./docker-logs-modal"; From 3dbaaf70fc2077e59906763784d253699126d3ba Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 6 Jan 2026 18:56:10 +0100 Subject: [PATCH 05/15] feat(docker): add logs action and stats columns to management table --- .../manage/tools/docker/docker-table.tsx | 110 ++++++++++++++++-- 1 file changed, 103 insertions(+), 7 deletions(-) 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 f8c8509f5..45408fe99 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,9 +1,11 @@ "use client"; +import { useEffect, useState } from "react"; import type { MantineColor } from "@mantine/core"; import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core"; import { IconCategoryPlus, + IconFileText, IconPlayerPlay, IconPlayerStop, IconRefresh, @@ -15,11 +17,11 @@ import { MantineReactTable } from "mantine-react-table"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import { useTimeAgo } from "@homarr/common"; +import { humanFileSize, useTimeAgo } from "@homarr/common"; import type { ContainerState } from "@homarr/docker"; import { containerStateColorMap } from "@homarr/docker/shared"; import { useModalAction } from "@homarr/modals"; -import { AddDockerAppToHomarr } from "@homarr/modals-collection"; +import { AddDockerAppToHomarr, DockerLogsModal } from "@homarr/modals-collection"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import type { TranslationFunction } from "@homarr/translation"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; @@ -35,17 +37,19 @@ const createColumns = ( header: t("docker.field.name.label"), Cell({ renderedCellValue, row }) { return ( - + {row.original.name.at(0)?.toUpperCase()} - {renderedCellValue} + + {renderedCellValue} + ); }, @@ -76,17 +80,62 @@ const createColumns = ( accessorKey: "ports", header: t("docker.field.ports.label"), Cell({ cell }) { - if (!cell.row.original.ports.length) return null; + if (!cell?.row?.original?.ports?.length) return null; return ( port.PrivatePort.toString())} /> ); }, }, + { + id: "cpuUsage", + accessorKey: "cpuUsage", + size: 120, + header: t("docker.field.stats.cpu.label"), + enableHiding: true, + sortingFn: (rowA, rowB) => { + const cpuUsageA = safeValue(rowA.original.cpuUsage); + const cpuUsageB = safeValue(rowB.original.cpuUsage); + + return cpuUsageA - cpuUsageB; + }, + Cell({ row }) { + const cpuUsage = safeValue(row.original.cpuUsage); + + return ( + + {cpuUsage.toFixed(2)}% + + ); + }, + }, + { + id: "memoryUsage", + accessorKey: "memoryUsage", + size: 140, + header: t("docker.field.stats.memory.label"), + enableHiding: true, + sortingFn: (rowA, rowB) => { + const memoryUsageA = safeValue(rowA.original.memoryUsage); + const memoryUsageB = safeValue(rowB.original.memoryUsage); + + return memoryUsageA - memoryUsageB; + }, + Cell({ row }) { + const bytesUsage = safeValue(row.original.memoryUsage); + + return ( + + {humanFileSize(bytesUsage)} + + ); + }, + }, ]; 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, @@ -111,7 +160,7 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers" autoFocus: true, }, - initialState: { density: "xs", showGlobalFilter: true }, + initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } }, renderTopToolbarCustomActions: () => { const utils = clientApi.useUtils(); const { mutate, isPending } = clientApi.docker.invalidate.useMutation({ @@ -160,6 +209,14 @@ 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 })} @@ -175,6 +232,7 @@ interface ContainerActionBarProps { const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => { const t = useScopedI18n("docker.action"); const { openModal } = useModalAction(AddDockerAppToHomarr); + const { openModal: openLogsModal } = useModalAction(DockerLogsModal); const handleClick = () => { openModal({ selectedContainers, @@ -183,12 +241,31 @@ const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => const selectedIds = selectedContainers.map((container) => container.id); + const handleShowLogs = () => { + const targetContainer = selectedContainers.at(0); + if (!targetContainer) return; + openLogsModal( + { id: targetContainer.id, name: targetContainer.name }, + { title: t("logs.modal.title", { name: targetContainer.name }) }, + ); + }; + return ( + @@ -256,3 +333,22 @@ 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); From 31323ed6741b65da6fd0155568a50bae6720663b Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Tue, 6 Jan 2026 18:56:20 +0100 Subject: [PATCH 06/15] feat(i18n): add docker logs labels --- packages/translation/src/lang/en.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 517ecfea1..72751998c 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3586,6 +3586,12 @@ }, "action": { "title": "Actions", + "logs": { + "label": "View logs", + "modal": { + "title": "{name} logs" + } + }, "start": { "label": "Start", "notification": { From 9bf28c00cdb362fe9ca812b39b47b996aa123a0a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 19:48:18 +0000 Subject: [PATCH 07/15] Initial plan From 762cdab78f0bb2f5df7a6d8c24dc4da8cbc1ce47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 19:54:03 +0000 Subject: [PATCH 08/15] Address review comments 1-4: Fix optional checks, remove redundant code, simplify column visibility, and share utility functions Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com> --- .../manage/tools/docker/docker-table.tsx | 35 +++---------------- packages/docker/src/shared.ts | 19 ++++++++++ packages/widgets/src/docker/component.tsx | 21 +---------- 3 files changed, 24 insertions(+), 51 deletions(-) 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/docker/src/shared.ts b/packages/docker/src/shared.ts index f50bd32c7..d63598e21 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) => (value !== undefined && !isNaN(value) ? value : fallback); 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)", From 23ce0d14ae9c77eba52fb71ed58ca254452fabb7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 19:57:33 +0000 Subject: [PATCH 09/15] Implement log streaming with subscription-based approach instead of polling Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com> --- .../api/src/router/docker/docker-router.ts | 51 ++++++++++++- .../src/docker/docker-logs-modal.tsx | 72 ++++++++++++++----- packages/request-handler/src/docker.ts | 51 +++++++++++++ 3 files changed, 156 insertions(+), 18 deletions(-) diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index 013c8e386..dba8d0ad0 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,51 @@ 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 cleanup: (() => void) | undefined; + + streamContainerLogsAsync( + input.id, + input.tail ?? 200, + (data) => { + emit.next(data); + }, + (err) => { + emit.error( + new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: err.message, + }), + ); + }, + ) + .then((cleanupFn) => { + cleanup = cleanupFn; + }) + .catch((err) => { + emit.error( + new TRPCError({ + code: "NOT_FOUND", + message: err instanceof Error ? err.message : "Container not found", + }), + ); + }); + + return () => { + cleanup?.(); + }; + }); + }), }); const getContainerOrDefaultAsync = async (instance: Docker, id: string) => { diff --git a/packages/modals-collection/src/docker/docker-logs-modal.tsx b/packages/modals-collection/src/docker/docker-logs-modal.tsx index 2cee0534c..c4471e6ff 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"; @@ -16,41 +16,79 @@ interface DockerLogsModalInnerProps { 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 { 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, }, ); + // Subscribe to streaming logs + clientApi.docker.subscribeLogs.useSubscription( + { id: innerProps.id, tail: innerProps.tail ?? 200 }, + { + onData(data) { + setLogs((prev) => prev + data); + setIsLoading(false); + }, + onError(err) { + setError(err.message); + setIsLoading(false); + }, + }, + ); + + // Initialize with static logs useEffect(() => { - if (didInitialScrollRef.current) return; - if (!data?.logs) return; + if (initialData?.logs) { + setLogs(initialData.logs); + setIsLoading(false); + } + }, [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]); + + // Check if user has scrolled up + const isAtBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 100; + if (isAtBottom) { + viewport.scrollTop = viewport.scrollHeight; + } + }, [logs]); + + // Handle manual scroll to determine auto-scroll behavior + const handleScroll = () => { + const viewport = viewportRef.current; + if (!viewport) return; + + const isAtBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 100; + shouldScrollRef.current = isAtBottom; + }; 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..32fe9e0f4 100644 --- a/packages/request-handler/src/docker.ts +++ b/packages/request-handler/src/docker.ts @@ -57,6 +57,57 @@ 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 multiplexed stream processing + let buffer = Buffer.alloc(0); + + const processChunk = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + + while (buffer.length >= 8) { + const length = buffer.readUInt32BE(4); + const fullMessageSize = 8 + length; + + if (buffer.length < fullMessageSize) { + // Wait for more data + break; + } + + const payload = buffer.subarray(8, 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; From 9e9e259fd1a1573b83ce1e9c8e8b12d5949b77da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 19:58:41 +0000 Subject: [PATCH 10/15] Add return type annotation to safeValue function for consistency Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com> --- packages/docker/src/shared.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docker/src/shared.ts b/packages/docker/src/shared.ts index d63598e21..a7d9b79dc 100644 --- a/packages/docker/src/shared.ts +++ b/packages/docker/src/shared.ts @@ -29,4 +29,4 @@ export const cpuUsageColor = (number: number, state: string): MantineColor => { return "red"; }; -export const safeValue = (value?: number, fallback = 0) => (value !== undefined && !isNaN(value) ? value : fallback); +export const safeValue = (value?: number, fallback = 0): number => (value !== undefined && !isNaN(value) ? value : fallback); From 4497f6ca6a86ef0136432a9de709a0b861e96cdc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 20:00:08 +0000 Subject: [PATCH 11/15] Refactor docker logs modal: extract helper function and prevent duplicate initial logs Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com> --- .../src/docker/docker-logs-modal.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/modals-collection/src/docker/docker-logs-modal.tsx b/packages/modals-collection/src/docker/docker-logs-modal.tsx index c4471e6ff..36f6e7c4c 100644 --- a/packages/modals-collection/src/docker/docker-logs-modal.tsx +++ b/packages/modals-collection/src/docker/docker-logs-modal.tsx @@ -13,6 +13,13 @@ 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); @@ -20,6 +27,7 @@ export const DockerLogsModal = createModal(({ innerPr const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const shouldScrollRef = useRef(true); + const hasInitializedRef = useRef(false); // Get initial logs const { data: initialData } = clientApi.docker.logs.useQuery( @@ -45,11 +53,12 @@ export const DockerLogsModal = createModal(({ innerPr }, ); - // Initialize with static logs + // Initialize with static logs (only once) useEffect(() => { - if (initialData?.logs) { + if (initialData?.logs && !hasInitializedRef.current) { setLogs(initialData.logs); setIsLoading(false); + hasInitializedRef.current = true; } }, [initialData]); @@ -59,9 +68,7 @@ export const DockerLogsModal = createModal(({ innerPr const viewport = viewportRef.current; if (!viewport) return; - // Check if user has scrolled up - const isAtBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 100; - if (isAtBottom) { + if (isViewportAtBottom(viewport)) { viewport.scrollTop = viewport.scrollHeight; } }, [logs]); @@ -69,10 +76,7 @@ export const DockerLogsModal = createModal(({ innerPr // Handle manual scroll to determine auto-scroll behavior const handleScroll = () => { const viewport = viewportRef.current; - if (!viewport) return; - - const isAtBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 100; - shouldScrollRef.current = isAtBottom; + shouldScrollRef.current = isViewportAtBottom(viewport); }; return ( From 8b7d849cd01ba37273a34543708d30fde34395ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 20:01:39 +0000 Subject: [PATCH 12/15] Address code review nitpicks: improve comments and use named constant for header size Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com> --- .../src/docker/docker-logs-modal.tsx | 2 +- packages/request-handler/src/docker.ts | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/modals-collection/src/docker/docker-logs-modal.tsx b/packages/modals-collection/src/docker/docker-logs-modal.tsx index 36f6e7c4c..e75508681 100644 --- a/packages/modals-collection/src/docker/docker-logs-modal.tsx +++ b/packages/modals-collection/src/docker/docker-logs-modal.tsx @@ -67,7 +67,7 @@ export const DockerLogsModal = createModal(({ innerPr if (!shouldScrollRef.current) return; const viewport = viewportRef.current; if (!viewport) return; - + if (isViewportAtBottom(viewport)) { viewport.scrollTop = viewport.scrollHeight; } diff --git a/packages/request-handler/src/docker.ts b/packages/request-handler/src/docker.ts index 32fe9e0f4..779b8a61c 100644 --- a/packages/request-handler/src/docker.ts +++ b/packages/request-handler/src/docker.ts @@ -76,22 +76,27 @@ export const streamContainerLogsAsync = async ( follow: true, }); - // Docker multiplexed stream processing + // 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; let buffer = Buffer.alloc(0); const processChunk = (chunk: Buffer) => { buffer = Buffer.concat([buffer, chunk]); - while (buffer.length >= 8) { + while (buffer.length >= DOCKER_STREAM_HEADER_SIZE) { const length = buffer.readUInt32BE(4); - const fullMessageSize = 8 + length; + const fullMessageSize = DOCKER_STREAM_HEADER_SIZE + length; if (buffer.length < fullMessageSize) { // Wait for more data break; } - const payload = buffer.subarray(8, fullMessageSize); + const payload = buffer.subarray(DOCKER_STREAM_HEADER_SIZE, fullMessageSize); onData(payload.toString("utf-8")); buffer = buffer.subarray(fullMessageSize); From 58a2c508c60481a03d5a57e2d2ca148ae53ceb3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 20:03:27 +0000 Subject: [PATCH 13/15] Fix race condition in subscription cleanup and add log size limit for memory management Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com> --- .../api/src/router/docker/docker-router.ts | 64 +++++++++++-------- .../src/docker/docker-logs-modal.tsx | 15 ++++- 2 files changed, 51 insertions(+), 28 deletions(-) diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index dba8d0ad0..213789023 100644 --- a/packages/api/src/router/docker/docker-router.ts +++ b/packages/api/src/router/docker/docker-router.ts @@ -142,37 +142,47 @@ export const dockerRouter = createTRPCRouter({ ) .subscription(({ input }) => { return observable((emit) => { - let cleanup: (() => void) | undefined; + let cleanupFn: (() => void) | undefined; + let isSubscribed = true; - streamContainerLogsAsync( - input.id, - input.tail ?? 200, - (data) => { - emit.next(data); - }, - (err) => { - emit.error( - new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: err.message, - }), + 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, + }), + ); + } + }, ); - }, - ) - .then((cleanupFn) => { - cleanup = cleanupFn; - }) - .catch((err) => { - emit.error( - new TRPCError({ - code: "NOT_FOUND", - message: err instanceof Error ? err.message : "Container not found", - }), - ); - }); + } catch (err) { + if (isSubscribed) { + emit.error( + new TRPCError({ + code: "NOT_FOUND", + message: err instanceof Error ? err.message : "Container not found", + }), + ); + } + } + }; + + void initializeStream(); return () => { - cleanup?.(); + isSubscribed = false; + cleanupFn?.(); }; }); }), diff --git a/packages/modals-collection/src/docker/docker-logs-modal.tsx b/packages/modals-collection/src/docker/docker-logs-modal.tsx index e75508681..d0924a5dc 100644 --- a/packages/modals-collection/src/docker/docker-logs-modal.tsx +++ b/packages/modals-collection/src/docker/docker-logs-modal.tsx @@ -38,12 +38,25 @@ export const DockerLogsModal = createModal(({ innerPr }, ); + // 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) => prev + 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) { From c351c7e09b5f46ea206aa1eddb8075fc59ccd7fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 20:04:57 +0000 Subject: [PATCH 14/15] Add payload size validation to prevent memory exhaustion from corrupted Docker streams Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com> --- packages/request-handler/src/docker.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/request-handler/src/docker.ts b/packages/request-handler/src/docker.ts index 779b8a61c..c73eaf014 100644 --- a/packages/request-handler/src/docker.ts +++ b/packages/request-handler/src/docker.ts @@ -82,6 +82,7 @@ export const streamContainerLogsAsync = async ( // - 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) => { @@ -89,6 +90,13 @@ export const streamContainerLogsAsync = async ( 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) { From 379dc8f40782814fdd2e020b114065916a0be355 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 21 May 2026 14:18:21 +0200 Subject: [PATCH 15/15] feat(docker): replace logs modal with dedicated xterm page Address PR review feedback by moving container logs to a full-page viewer using xterm (matching /manage/tools/logs), with per-row actions for better UX when browsing long container lists. Harden log streaming with multiplex decode fallbacks, stream cleanup on parse errors, and decode unit tests. --- .../manage/tools/docker/docker-table.tsx | 53 ++++--- .../docker/logs/[containerId]/client.tsx | 10 ++ .../tools/docker/logs/[containerId]/page.tsx | 50 +++++++ .../logs/[containerId]/terminal.module.css | 3 + .../docker/logs/[containerId]/terminal.tsx | 79 +++++++++++ .../api/src/router/docker/docker-router.ts | 4 +- .../router/test/docker/docker-router.spec.ts | 10 ++ packages/docker/src/shared.ts | 3 +- .../src/docker/docker-logs-modal.tsx | 134 ------------------ .../modals-collection/src/docker/index.ts | 1 - .../src/docker-log-decode.spec.ts | 53 +++++++ .../request-handler/src/docker-log-decode.ts | 89 ++++++++++++ packages/request-handler/src/docker.ts | 78 +++------- packages/translation/src/lang/en.json | 2 +- 14 files changed, 350 insertions(+), 219 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/client.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/page.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.module.css create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.tsx delete mode 100644 packages/modals-collection/src/docker/docker-logs-modal.tsx create mode 100644 packages/request-handler/src/docker-log-decode.spec.ts create mode 100644 packages/request-handler/src/docker-log-decode.ts 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 d3bc01049..6d2c9fb04 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,7 +1,8 @@ "use client"; +import { useRouter } from "next/navigation"; import type { MantineColor } from "@mantine/core"; -import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core"; +import { ActionIcon, Avatar, Badge, Box, Button, Group, Text, Tooltip } from "@mantine/core"; import { IconCategoryPlus, IconFileText, @@ -11,7 +12,7 @@ import { IconRotateClockwise, IconTrash, } from "@tabler/icons-react"; -import type { MRT_ColumnDef } from "mantine-react-table"; +import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table"; import { MantineReactTable } from "mantine-react-table"; import type { RouterOutputs } from "@homarr/api"; @@ -20,7 +21,7 @@ import { humanFileSize, useTimeAgo } from "@homarr/common"; import type { ContainerState } from "@homarr/docker"; import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared"; import { useModalAction } from "@homarr/modals"; -import { AddDockerAppToHomarr, DockerLogsModal } from "@homarr/modals-collection"; +import { AddDockerAppToHomarr } from "@homarr/modals-collection"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import type { TranslationFunction } from "@homarr/translation"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; @@ -28,9 +29,12 @@ import type { TablerIcon } from "@homarr/ui"; import { OverflowBadge } from "@homarr/ui"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; -const createColumns = ( - t: TranslationFunction, -): MRT_ColumnDef[] => [ +type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number]; + +const createContainerLogsPath = (container: Pick) => + `/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`; + +const createColumns = (t: TranslationFunction): MRT_ColumnDef[] => [ { accessorKey: "name", header: t("docker.field.name.label"), @@ -79,10 +83,9 @@ const createColumns = ( accessorKey: "ports", header: t("docker.field.ports.label"), Cell({ cell }) { - const ports = cell.row.original.ports; - if (!ports || ports.length === 0) return null; + if (!cell.row.original.ports.length) return null; return ( - port.PrivatePort.toString())} /> + port.PrivatePort.toString())} /> ); }, }, @@ -132,11 +135,12 @@ const createColumns = ( }, ]; -export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"]) { +export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) { const t = useI18n(); const tDocker = useScopedI18n("docker"); + const router = useRouter(); const { data } = clientApi.docker.getContainers.useQuery(undefined, { - initialData, + initialData: { containers, timestamp }, refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, @@ -149,6 +153,8 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers" enableColumnFilters: false, enablePagination: false, enableRowSelection: true, + enableRowActions: true, + positionActionsColumn: "last", positionToolbarAlertBanner: "top", enableTableFooter: false, enableBottomToolbar: false, @@ -160,6 +166,21 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers" }, initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } }, + renderRowActions: ({ row }: { row: MRT_Row }) => ( + + { + event.stopPropagation(); + router.push(createContainerLogsPath(row.original)); + }} + > + + + + ), renderTopToolbarCustomActions: () => { const utils = clientApi.useUtils(); const { mutate, isPending } = clientApi.docker.invalidate.useMutation({ @@ -217,13 +238,13 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers" } interface ContainerActionBarProps { - selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"]; + selectedContainers: DockerContainer[]; } const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => { const t = useScopedI18n("docker.action"); + const router = useRouter(); const { openModal } = useModalAction(AddDockerAppToHomarr); - const { openModal: openLogsModal } = useModalAction(DockerLogsModal); const handleClick = () => { openModal({ selectedContainers, @@ -235,10 +256,7 @@ const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => const handleShowLogs = () => { const targetContainer = selectedContainers.at(0); if (!targetContainer) return; - openLogsModal( - { id: targetContainer.id, name: targetContainer.name }, - { title: t("logs.modal.title", { name: targetContainer.name }) }, - ); + router.push(createContainerLogsPath(targetContainer)); }; return ( @@ -324,4 +342,3 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => { ); }; - diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/client.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/client.tsx new file mode 100644 index 000000000..4bc5de800 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/client.tsx @@ -0,0 +1,10 @@ +"use client"; + +import dynamic from "next/dynamic"; + +export const ClientSideDockerLogsTerminal = dynamic( + () => import("./terminal").then(({ DockerLogsTerminal }) => DockerLogsTerminal), + { + ssr: false, + }, +); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/page.tsx new file mode 100644 index 000000000..6d41382d5 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/page.tsx @@ -0,0 +1,50 @@ +import { notFound } from "next/navigation"; +import { Box, Group, Title } from "@mantine/core"; + +import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; +import { env } from "@homarr/docker/env"; +import { getScopedI18n } from "@homarr/translation/server"; + +import "@xterm/xterm/css/xterm.css"; + +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import { fullHeightWithoutHeaderAndFooter } from "~/constants"; +import { ClientSideDockerLogsTerminal } from "./client"; + +interface DockerContainerLogsPageProps { + params: Promise<{ containerId: string }>; + searchParams: Promise<{ name?: string }>; +} + +export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) { + const session = await auth(); + if (!(session?.user.permissions.includes("admin") && env.ENABLE_DOCKER)) { + notFound(); + } + + const { containerId } = await params; + const { name } = await searchParams; + + try { + await api.docker.logs({ id: containerId, tail: 1 }); + } catch { + notFound(); + } + + const tDocker = await getScopedI18n("docker"); + + return ( + <> + + + + + {tDocker("action.logs.page.title", { name: name ?? containerId })} + + + + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.module.css b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.module.css new file mode 100644 index 000000000..c654cf6de --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.module.css @@ -0,0 +1,3 @@ +.outerTerminal > div { + height: 100%; +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.tsx new file mode 100644 index 000000000..6ff6cf196 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { Box } from "@mantine/core"; +import { CanvasAddon } from "@xterm/addon-canvas"; +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; + +import { clientApi } from "@homarr/api/client"; + +import classes from "./terminal.module.css"; + +interface DockerLogsTerminalProps { + containerId: string; +} + +export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) => { + const ref = useRef(null); + const terminalRef = useRef(null); + const pendingLogsRef = useRef([]); + const isTerminalReadyRef = useRef(false); + + const writeToTerminal = useCallback((data: string) => { + if (!isTerminalReadyRef.current || !terminalRef.current) { + pendingLogsRef.current.push(data); + return; + } + + terminalRef.current.write(data); + terminalRef.current.refresh(0, terminalRef.current.rows - 1); + }, []); + + clientApi.docker.subscribeLogs.useSubscription( + { id: containerId, tail: 200 }, + { + onData: writeToTerminal, + onError(err) { + writeToTerminal(`\r\n\x1b[31m${err.message}\x1b[0m`); + }, + }, + ); + + useEffect(() => { + if (!ref.current) { + return () => undefined; + } + + const canvasAddon = new CanvasAddon(); + + terminalRef.current = new Terminal({ + cursorBlink: false, + disableStdin: true, + convertEol: true, + }); + terminalRef.current.open(ref.current); + terminalRef.current.loadAddon(canvasAddon); + + isTerminalReadyRef.current = true; + const terminal = terminalRef.current; + pendingLogsRef.current.forEach((data) => terminal.write(data)); + pendingLogsRef.current = []; + terminal.refresh(0, terminal.rows - 1); + + setTimeout(() => { + const fitAddon = new FitAddon(); + terminalRef.current?.loadAddon(fitAddon); + fitAddon.fit(); + }); + + return () => { + isTerminalReadyRef.current = false; + pendingLogsRef.current = []; + terminalRef.current?.dispose(); + canvasAddon.dispose(); + }; + }, [writeToTerminal]); + + return ; +}; diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index 213789023..2af05cad1 100644 --- a/packages/api/src/router/docker/docker-router.ts +++ b/packages/api/src/router/docker/docker-router.ts @@ -145,7 +145,7 @@ export const dockerRouter = createTRPCRouter({ let cleanupFn: (() => void) | undefined; let isSubscribed = true; - const initializeStream = async () => { + const initializeStreamAsync = async () => { try { cleanupFn = await streamContainerLogsAsync( input.id, @@ -178,7 +178,7 @@ export const dockerRouter = createTRPCRouter({ } }; - void initializeStream(); + void initializeStreamAsync(); return () => { isSubscribed = false; diff --git a/packages/api/src/router/test/docker/docker-router.spec.ts b/packages/api/src/router/test/docker/docker-router.spec.ts index 941e696e6..cbea4063b 100644 --- a/packages/api/src/router/test/docker/docker-router.spec.ts +++ b/packages/api/src/router/test/docker/docker-router.spec.ts @@ -13,6 +13,14 @@ import { dockerRouter } from "../../docker/docker-router"; // Mock the auth module to return an empty session vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); vi.mock("@homarr/request-handler/docker", () => ({ + getContainerLogsAsync: async () => { + await Promise.resolve(); + return "logs"; + }, + streamContainerLogsAsync: async () => { + await Promise.resolve(); + return () => undefined; + }, dockerContainersRequestHandler: { handler: () => ({ getCachedOrUpdatedDataAsync: async () => { @@ -65,6 +73,8 @@ const validInputs: { restartAll: { ids: ["1"] }, removeAll: { ids: ["1"] }, invalidate: undefined, + logs: { id: "1", tail: 200 }, + subscribeLogs: { id: "1", tail: 200 }, }; describe("All procedures should only be accessible for users with admin permission", () => { diff --git a/packages/docker/src/shared.ts b/packages/docker/src/shared.ts index a7d9b79dc..e98695278 100644 --- a/packages/docker/src/shared.ts +++ b/packages/docker/src/shared.ts @@ -29,4 +29,5 @@ export const cpuUsageColor = (number: number, state: string): MantineColor => { return "red"; }; -export const safeValue = (value?: number, fallback = 0): number => (value !== undefined && !isNaN(value) ? value : fallback); +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 deleted file mode 100644 index d0924a5dc..000000000 --- a/packages/modals-collection/src/docker/docker-logs-modal.tsx +++ /dev/null @@ -1,134 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { Box, Group, Loader, ScrollArea, Stack, Text } from "@mantine/core"; - -import { clientApi } from "@homarr/api/client"; -import { createModal } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; - -interface DockerLogsModalInnerProps { - id: string; - name: string; - 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 [logs, setLogs] = useState(""); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const shouldScrollRef = useRef(true); - const hasInitializedRef = useRef(false); - - // Get initial logs - const { data: initialData } = clientApi.docker.logs.useQuery( - { id: innerProps.id, tail: innerProps.tail ?? 200 }, - { - refetchOnWindowFocus: false, - retry: 0, - }, - ); - - // 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 (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; - - 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 ( - - - {isLoading ? ( - - - - ) : error ? ( - - {error} - - ) : ( - - {logs ? ( -
-                {logs}
-              
- ) : ( - - {t("common.state.empty")} - - )} -
- )} -
-
- ); -}).withOptions({ - size: "95%", - centered: true, - defaultTitle: (t) => t("docker.action.logs.label"), -}); - - diff --git a/packages/modals-collection/src/docker/index.ts b/packages/modals-collection/src/docker/index.ts index bb32f7770..31b956245 100644 --- a/packages/modals-collection/src/docker/index.ts +++ b/packages/modals-collection/src/docker/index.ts @@ -1,2 +1 @@ export { AddDockerAppToHomarrModal as AddDockerAppToHomarr } from "./add-docker-app-to-homarr"; -export { DockerLogsModal } from "./docker-logs-modal"; diff --git a/packages/request-handler/src/docker-log-decode.spec.ts b/packages/request-handler/src/docker-log-decode.spec.ts new file mode 100644 index 000000000..b82dfd833 --- /dev/null +++ b/packages/request-handler/src/docker-log-decode.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "vitest"; + +import { createDockerLogStreamProcessor, decodeDockerLogs } from "./docker-log-decode"; + +const encodeDockerLogFrame = (streamType: number, message: string) => { + const payload = Buffer.from(message, "utf-8"); + const header = Buffer.alloc(8); + header.writeUInt8(streamType, 0); + header.writeUInt32BE(payload.length, 4); + return Buffer.concat([header, payload]); +}; + +describe("decodeDockerLogs", () => { + test("decodes multiplexed docker log frames", () => { + const logs = Buffer.concat([encodeDockerLogFrame(1, "hello "), encodeDockerLogFrame(2, "world")]); + + expect(decodeDockerLogs(logs)).toBe("hello world"); + }); + + test("returns plain text logs unchanged", () => { + expect(decodeDockerLogs("plain text log")).toBe("plain text log"); + }); + + test("falls back to raw buffer text for non-multiplexed output", () => { + expect(decodeDockerLogs(Buffer.from("raw docker output", "utf-8"))).toBe("raw docker output"); + }); +}); + +describe("createDockerLogStreamProcessor", () => { + test("emits decoded chunks from a stream", () => { + const messages: string[] = []; + const processChunk = createDockerLogStreamProcessor( + (data) => messages.push(data), + () => undefined, + 1024, + ); + + const chunk = encodeDockerLogFrame(1, "streamed log"); + expect(processChunk(chunk)).toBe(true); + expect(messages).toEqual(["streamed log"]); + }); + + test("returns false when message size exceeds limit", () => { + const processChunk = createDockerLogStreamProcessor( + () => undefined, + () => undefined, + 4, + ); + + const chunk = encodeDockerLogFrame(1, "this message is too long"); + expect(processChunk(chunk)).toBe(false); + }); +}); diff --git a/packages/request-handler/src/docker-log-decode.ts b/packages/request-handler/src/docker-log-decode.ts new file mode 100644 index 000000000..a069091d6 --- /dev/null +++ b/packages/request-handler/src/docker-log-decode.ts @@ -0,0 +1,89 @@ +const DOCKER_STREAM_HEADER_SIZE = 8; +const DOCKER_STREAM_TYPES = new Set([1, 2]); + +export const decodeDockerLogs = (logs: Buffer | string) => { + if (typeof logs === "string") { + return logs; + } + + if (logs.length === 0) { + return ""; + } + + let cursor = 0; + const parts: string[] = []; + + while (cursor < logs.length) { + if (cursor + DOCKER_STREAM_HEADER_SIZE > logs.length) { + break; + } + + const streamType = logs.readUInt8(cursor); + if (!DOCKER_STREAM_TYPES.has(streamType)) { + return logs.toString("utf-8"); + } + + const length = logs.readUInt32BE(cursor + 4); + const start = cursor + DOCKER_STREAM_HEADER_SIZE; + const end = start + length; + if (end > logs.length) { + break; + } + + parts.push(logs.subarray(start, end).toString("utf-8")); + cursor = end; + } + + const decoded = parts.join(""); + if (decoded.length === 0) { + return logs.toString("utf-8"); + } + + return decoded; +}; + +export const createDockerLogStreamProcessor = ( + onData: (data: string) => void, + onError: (err: Error) => void, + maxMessageSize: number, +) => { + let buffer = Buffer.alloc(0); + + const processChunk = (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + + while (buffer.length > 0) { + if (buffer.length < DOCKER_STREAM_HEADER_SIZE) { + break; + } + + const streamType = buffer.readUInt8(0); + if (!DOCKER_STREAM_TYPES.has(streamType)) { + onData(buffer.toString("utf-8")); + buffer = Buffer.alloc(0); + break; + } + + const length = buffer.readUInt32BE(4); + if (length > maxMessageSize) { + onError( + new Error(`Docker log message size (${length} bytes) exceeds maximum allowed (${maxMessageSize} bytes)`), + ); + return false; + } + + const fullMessageSize = DOCKER_STREAM_HEADER_SIZE + length; + if (buffer.length < fullMessageSize) { + break; + } + + const payload = buffer.subarray(DOCKER_STREAM_HEADER_SIZE, fullMessageSize); + onData(payload.toString("utf-8")); + buffer = buffer.subarray(fullMessageSize); + } + + return true; + }; + + return processChunk; +}; diff --git a/packages/request-handler/src/docker.ts b/packages/request-handler/src/docker.ts index c73eaf014..7bfb82a2b 100644 --- a/packages/request-handler/src/docker.ts +++ b/packages/request-handler/src/docker.ts @@ -1,3 +1,4 @@ +import type { Readable } from "node:stream"; import dayjs from "dayjs"; import type { Container, ContainerInfo, ContainerStats } from "dockerode"; @@ -6,6 +7,7 @@ import { icons } from "@homarr/db/schema"; import type { ContainerState } from "@homarr/docker"; import { dockerLabels, DockerSingleton } from "@homarr/docker"; +import { createDockerLogStreamProcessor, decodeDockerLogs } from "./docker-log-decode"; import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({ @@ -69,89 +71,40 @@ export const streamContainerLogsAsync = async ( return () => undefined; } - const stream = await container.logs({ + const stream = (await container.logs({ tail, stdout: true, stderr: true, follow: true, - }); + })) as Readable; // 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 MAX_MESSAGE_SIZE = 1024 * 1024; + const processChunk = createDockerLogStreamProcessor(onData, onError, MAX_MESSAGE_SIZE); - 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); + const handleChunk = (chunk: Buffer) => { + const shouldContinue = processChunk(chunk); + if (!shouldContinue) { + stream.removeListener("data", handleChunk); + stream.removeListener("error", onError); + stream.destroy(); } }; - stream.on("data", processChunk); + stream.on("data", handleChunk); stream.on("error", onError); return () => { - stream.removeListener("data", processChunk); + stream.removeListener("data", handleChunk); stream.removeListener("error", onError); stream.destroy(); }; }; -const decodeDockerLogs = (logs: Buffer | string) => { - if (typeof logs === "string") { - return logs; - } - - // Docker multiplexed stream: 1 byte stream type, 3 bytes padding, 4 bytes big-endian length, then payload - // Repeat until buffer consumed. - let cursor = 0; - const parts: string[] = []; - - while (cursor < logs.length) { - // Defensive: need at least 8 bytes header - if (cursor + 8 > logs.length) { - break; - } - - const length = logs.readUInt32BE(cursor + 4); - const start = cursor + 8; - const end = start + length; - if (end > logs.length) { - break; - } - - const payload = logs.subarray(start, end); - parts.push(payload.toString("utf-8")); - cursor = end; - } - - return parts.join(""); -}; - async function getContainersWithStatsAsync() { const containers = await Promise.all( dockerInstances.map(async ({ instance, host }) => { @@ -203,7 +156,8 @@ async function getContainersWithStatsAsync() { cpuUsage, memoryUsage, image: container.Image, - ports: container.Ports, + // Docker Desktop on macOS can return null instead of an empty ports array + ports: (container.Ports as typeof container.Ports | null) ?? [], }; }); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 0e7d0568e..d6c39e0c8 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3595,7 +3595,7 @@ "title": "Actions", "logs": { "label": "View logs", - "modal": { + "page": { "title": "{name} logs" } },