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] 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;