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" } },