Merge pull request #4808 from homarr-labs/copilot/update-docker-table-code
This commit is contained in:
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) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in new issue
Block a user