Merge pull request #4808 from homarr-labs/copilot/update-docker-table-code

This commit is contained in:
Thomas Camlong authored and GitHub committed 2026-01-06 21:18:21 +01:00
commit 6179c47606
6 files changed
+220 -69

No files matched your search

@@ -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 (
<OverflowBadge overflowCount={1} data={cell.row.original.ports.map((port) => port.PrivatePort.toString())} />
<OverflowBadge overflowCount={1} data={ports.map((port) => 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 (
<>
<Text>{tDocker("table.updated", { when: relativeTime })}</Text>
@@ -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);
@@ -4,7 +4,11 @@ import { z } from "zod/v4";
import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
import { DockerSingleton } from "@homarr/docker";
import { dockerContainersRequestHandler, getContainerLogsAsync } from "@homarr/request-handler/docker";
import {
dockerContainersRequestHandler,
getContainerLogsAsync,
streamContainerLogsAsync,
} from "@homarr/request-handler/docker";
import { dockerMiddleware } from "../../middlewares/docker";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
@@ -127,6 +131,61 @@ export const dockerRouter = createTRPCRouter({
logs,
};
}),
subscribeLogs: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.input(
z.object({
id: z.string(),
tail: z.number().min(1).max(1000).optional(),
}),
)
.subscription(({ input }) => {
return observable<string>((emit) => {
let cleanupFn: (() => void) | undefined;
let isSubscribed = true;
const initializeStream = async () => {
try {
cleanupFn = await streamContainerLogsAsync(
input.id,
input.tail ?? 200,
(data) => {
if (isSubscribed) {
emit.next(data);
}
},
(err) => {
if (isSubscribed) {
emit.error(
new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: err.message,
}),
);
}
},
);
} catch (err) {
if (isSubscribed) {
emit.error(
new TRPCError({
code: "NOT_FOUND",
message: err instanceof Error ? err.message : "Container not found",
}),
);
}
}
};
void initializeStream();
return () => {
isSubscribed = false;
cleanupFn?.();
};
});
}),
});
const getContainerOrDefaultAsync = async (instance: Docker, id: string) => {
+19
View File
@@ -11,3 +11,22 @@ export const containerStateColorMap = {
removing: "pink",
dead: "dark",
} satisfies Record<ContainerState, MantineColor>;
export const memoryUsageColor = (number: number, state: string): MantineColor => {
const mbUsage = number / 1024 / 1024;
if (mbUsage === 0 && state !== "running") return "red";
if (mbUsage < 128) return "green";
if (mbUsage < 256) return "yellow";
if (mbUsage < 512) return "orange";
return "red";
};
export const cpuUsageColor = (number: number, state: string): MantineColor => {
if (number === 0 && state !== "running") return "red";
if (number < 40) return "green";
if (number < 60) return "yellow";
if (number < 90) return "orange";
return "red";
};
export const safeValue = (value?: number, fallback = 0): number => (value !== undefined && !isNaN(value) ? value : fallback);
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { Box, Group, Loader, ScrollArea, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
@@ -13,44 +13,99 @@ interface DockerLogsModalInnerProps {
tail?: number;
}
const SCROLL_THRESHOLD = 100;
const isViewportAtBottom = (viewport: HTMLDivElement | null): boolean => {
if (!viewport) return false;
return viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < SCROLL_THRESHOLD;
};
export const DockerLogsModal = createModal<DockerLogsModalInnerProps>(({ innerProps }) => {
const t = useI18n();
const viewportRef = useRef<HTMLDivElement | null>(null);
const didInitialScrollRef = useRef(false);
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);
const { data, isPending, isError } = clientApi.docker.logs.useQuery(
{ id: innerProps.id, tail: innerProps.tail },
// Get initial logs
const { data: initialData } = clientApi.docker.logs.useQuery(
{ id: innerProps.id, tail: innerProps.tail ?? 200 },
{
refetchOnWindowFocus: false,
retry: 0,
refetchInterval: 1000,
refetchIntervalInBackground: true,
},
);
// Maximum log size to prevent memory issues (500KB)
const MAX_LOG_SIZE = 500 * 1024;
// Subscribe to streaming logs
clientApi.docker.subscribeLogs.useSubscription(
{ id: innerProps.id, tail: innerProps.tail ?? 200 },
{
onData(data) {
setLogs((prev) => {
const newLogs = prev + data;
// Truncate from the beginning if logs exceed maximum size
if (newLogs.length > MAX_LOG_SIZE) {
const truncated = newLogs.slice(-MAX_LOG_SIZE);
// Try to start from a newline to avoid partial lines
const firstNewline = truncated.indexOf("\n");
return firstNewline > 0 ? truncated.slice(firstNewline + 1) : truncated;
}
return newLogs;
});
setIsLoading(false);
},
onError(err) {
setError(err.message);
setIsLoading(false);
},
},
);
// Initialize with static logs (only once)
useEffect(() => {
if (didInitialScrollRef.current) return;
if (!data?.logs) return;
if (initialData?.logs && !hasInitializedRef.current) {
setLogs(initialData.logs);
setIsLoading(false);
hasInitializedRef.current = true;
}
}, [initialData]);
// Auto-scroll to bottom when new logs arrive
useEffect(() => {
if (!shouldScrollRef.current) return;
const viewport = viewportRef.current;
if (!viewport) return;
viewport.scrollTop = viewport.scrollHeight;
didInitialScrollRef.current = true;
}, [data?.logs]);
if (isViewportAtBottom(viewport)) {
viewport.scrollTop = viewport.scrollHeight;
}
}, [logs]);
// Handle manual scroll to determine auto-scroll behavior
const handleScroll = () => {
const viewport = viewportRef.current;
shouldScrollRef.current = isViewportAtBottom(viewport);
};
return (
<Box>
<Stack gap="sm">
{isPending ? (
{isLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : isError ? (
) : error ? (
<Text c="red" size="sm">
Failed to load logs
{error}
</Text>
) : (
<ScrollArea h="75vh" viewportRef={viewportRef}>
{data?.logs ? (
<ScrollArea h="75vh" viewportRef={viewportRef} onScrollPositionChange={handleScroll}>
{logs ? (
<pre
style={{
margin: 0,
@@ -58,7 +113,7 @@ export const DockerLogsModal = createModal<DockerLogsModalInnerProps>(({ innerPr
overflowWrap: "anywhere",
}}
>
<code>{data.logs}</code>
<code>{logs}</code>
</pre>
) : (
<Text size="sm" c="dimmed">
+64
View File
@@ -57,6 +57,70 @@ export const getContainerLogsAsync = async (id: string, tail = 200) => {
return decodeDockerLogs(rawLogs);
};
export const streamContainerLogsAsync = async (
id: string,
tail: number,
onData: (data: string) => void,
onError: (err: Error) => void,
) => {
const container = await findContainerByIdAsync(id);
if (!container) {
onError(new Error("Container not found"));
return () => undefined;
}
const stream = await container.logs({
tail,
stdout: true,
stderr: true,
follow: true,
});
// Docker uses a multiplexed stream format for logs where each message has an 8-byte header:
// - 1 byte: stream type (stdout/stderr)
// - 3 bytes: padding
// - 4 bytes: payload length (big-endian)
// We need to process the stream in chunks and extract complete messages from the buffer.
const DOCKER_STREAM_HEADER_SIZE = 8;
const MAX_MESSAGE_SIZE = 1024 * 1024; // 1MB maximum message size for safety
let buffer = Buffer.alloc(0);
const processChunk = (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
while (buffer.length >= DOCKER_STREAM_HEADER_SIZE) {
const length = buffer.readUInt32BE(4);
// Validate message size to prevent memory exhaustion from corrupted data
if (length > MAX_MESSAGE_SIZE) {
onError(new Error(`Docker log message size (${length} bytes) exceeds maximum allowed (${MAX_MESSAGE_SIZE} bytes)`));
return;
}
const fullMessageSize = DOCKER_STREAM_HEADER_SIZE + length;
if (buffer.length < fullMessageSize) {
// Wait for more data
break;
}
const payload = buffer.subarray(DOCKER_STREAM_HEADER_SIZE, fullMessageSize);
onData(payload.toString("utf-8"));
buffer = buffer.subarray(fullMessageSize);
}
};
stream.on("data", processChunk);
stream.on("error", onError);
return () => {
stream.removeListener("data", processChunk);
stream.removeListener("error", onError);
stream.destroy();
};
};
const decodeDockerLogs = (logs: Buffer | string) => {
if (typeof logs === "string") {
return logs;
+1 -20
View File
@@ -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)",