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.
This commit is contained in:
Thomas Camlong committed 2026-05-21 14:18:21 +02:00
1 parent fb3da7e997
commit 379dc8f407
14 files changed
+350 -219

No files matched your search

@@ -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<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[] => [
{
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 (
<OverflowBadge overflowCount={1} data={ports.map((port) => port.PrivatePort.toString())} />
<OverflowBadge overflowCount={1} data={cell.row.original.ports.map((port) => 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<DockerContainer> }) => (
<Tooltip label={tDocker("action.logs.label")}>
<ActionIcon
variant="subtle"
color="blue"
aria-label={tDocker("action.logs.label")}
onClick={(event) => {
event.stopPropagation();
router.push(createContainerLogsPath(row.original));
}}
>
<IconFileText size="1rem" />
</ActionIcon>
</Tooltip>
),
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 }) => {
</Badge>
);
};
@@ -0,0 +1,10 @@
"use client";
import dynamic from "next/dynamic";
export const ClientSideDockerLogsTerminal = dynamic(
() => import("./terminal").then(({ DockerLogsTerminal }) => DockerLogsTerminal),
{
ssr: false,
},
);
@@ -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 (
<>
<Group justify="space-between" align="center" wrap="nowrap">
<DynamicBreadcrumb />
</Group>
<Title order={2} mb="md">
{tDocker("action.logs.page.title", { name: name ?? containerId })}
</Title>
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideDockerLogsTerminal containerId={containerId} />
</Box>
</>
);
}
@@ -0,0 +1,3 @@
.outerTerminal > div {
height: 100%;
}
@@ -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<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null);
const pendingLogsRef = useRef<string[]>([]);
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 <Box ref={ref} className={classes.outerTerminal} h="100%" />;
};
@@ -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;
@@ -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", () => {
+2 -1
View File
@@ -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;
@@ -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<DockerLogsModalInnerProps>(({ innerProps }) => {
const t = useI18n();
const viewportRef = useRef<HTMLDivElement | null>(null);
const [logs, setLogs] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<Box>
<Stack gap="sm">
{isLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : error ? (
<Text c="red" size="sm">
{error}
</Text>
) : (
<ScrollArea h="75vh" viewportRef={viewportRef} onScrollPositionChange={handleScroll}>
{logs ? (
<pre
style={{
margin: 0,
whiteSpace: "pre-wrap",
overflowWrap: "anywhere",
}}
>
<code>{logs}</code>
</pre>
) : (
<Text size="sm" c="dimmed">
{t("common.state.empty")}
</Text>
)}
</ScrollArea>
)}
</Stack>
</Box>
);
}).withOptions({
size: "95%",
centered: true,
defaultTitle: (t) => t("docker.action.logs.label"),
});
@@ -1,2 +1 @@
export { AddDockerAppToHomarrModal as AddDockerAppToHomarr } from "./add-docker-app-to-homarr";
export { DockerLogsModal } from "./docker-logs-modal";
@@ -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);
});
});
@@ -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;
};
+16 -62
View File
@@ -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) ?? [],
};
});
+1 -1
View File
@@ -3595,7 +3595,7 @@
"title": "Actions",
"logs": {
"label": "View logs",
"modal": {
"page": {
"title": "{name} logs"
}
},