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 f0a91d904..d2c1def97 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,23 +1,26 @@ "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, Menu, Text, Tooltip } from "@mantine/core"; import { IconCategoryPlus, + IconDots, + IconFileText, IconPlayerPlay, IconPlayerStop, IconRefresh, 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"; import { clientApi } from "@homarr/api/client"; -import { useTimeAgo } from "@homarr/common"; +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 } from "@homarr/modals-collection"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; @@ -27,25 +30,30 @@ 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"), Cell({ renderedCellValue, row }) { return ( - + {row.original.name.at(0)?.toUpperCase()} - {renderedCellValue} + + {renderedCellValue} + ); }, @@ -76,11 +84,11 @@ const createColumns = ( maxSize: 200, Cell({ renderedCellValue, cell }) { return ( - - - {renderedCellValue} - - + + + {renderedCellValue} + + ); }, }, @@ -95,13 +103,57 @@ const createColumns = ( ); }, }, + { + id: "cpuUsage", + accessorKey: "cpuUsage", + size: 120, + header: t("docker.field.stats.cpu.label"), + enableHiding: true, + sortingFn: (rowA, rowB) => { + const cpuUsageA = safeValue(rowA.original.cpuUsage); + const cpuUsageB = safeValue(rowB.original.cpuUsage); + + return cpuUsageA - cpuUsageB; + }, + Cell({ row }) { + const cpuUsage = safeValue(row.original.cpuUsage); + + return ( + + {cpuUsage.toFixed(2)}% + + ); + }, + }, + { + id: "memoryUsage", + accessorKey: "memoryUsage", + size: 140, + header: t("docker.field.stats.memory.label"), + enableHiding: true, + sortingFn: (rowA, rowB) => { + const memoryUsageA = safeValue(rowA.original.memoryUsage); + const memoryUsageB = safeValue(rowB.original.memoryUsage); + + return memoryUsageA - memoryUsageB; + }, + Cell({ row }) { + const bytesUsage = safeValue(row.original.memoryUsage); + + return ( + + {humanFileSize(bytesUsage)} + + ); + }, + }, ]; -export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"]) { +export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) { const t = useI18n(); const tDocker = useScopedI18n("docker"); const { data } = clientApi.docker.getContainers.useQuery(undefined, { - initialData, + initialData: { containers, timestamp }, refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, @@ -131,6 +183,8 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers" enableColumnFilters: false, enablePagination: false, enableRowSelection: true, + enableRowActions: true, + positionActionsColumn: "last", positionToolbarAlertBanner: "top", enableTableFooter: false, enableBottomToolbar: false, @@ -141,7 +195,10 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers" autoFocus: true, }, - initialState: { density: "xs", showGlobalFilter: true }, + initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } }, + mantineTableBodyCellProps: { style: { padding: "4px 8px" } }, + mantineTableHeadCellProps: { style: { padding: "4px 8px" } }, + renderRowActions: ({ row }: { row: MRT_Row }) => , renderTopToolbarCustomActions: () => ( 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..2c7a84521 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/page.tsx @@ -0,0 +1,47 @@ +import { notFound } from "next/navigation"; +import { Box, Group } from "@mantine/core"; + +import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; +import { env } from "@homarr/docker/env"; + +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(); + } + + return ( + <> + + + + + + + + ); +} 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..a8d0e1fa8 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/logs/[containerId]/terminal.tsx @@ -0,0 +1,81 @@ +"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, + fontSize: 14, + }); + 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 = []; + canvasAddon.dispose(); + terminalRef.current?.dispose(); + terminalRef.current = null; + }; + }, [writeToTerminal]); + + return ; +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx b/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx index 97f6ad150..20f50ffd9 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/logs/terminal.tsx @@ -57,8 +57,9 @@ export const TerminalComponent = () => { }); return () => { - terminalRef.current?.dispose(); canvasAddon.dispose(); + terminalRef.current?.dispose(); + terminalRef.current = null; }; }, []); diff --git a/apps/nextjs/src/components/layout/navigation-link.tsx b/apps/nextjs/src/components/layout/navigation-link.tsx index 9bdb91caa..80d58fa07 100644 --- a/apps/nextjs/src/components/layout/navigation-link.tsx +++ b/apps/nextjs/src/components/layout/navigation-link.tsx @@ -26,7 +26,7 @@ const NavLinkHref = (props: NavigationLinkHref) => { label={props.label} leftSection={props.icon} href={props.href} - active={pathname === props.href} + active={pathname === props.href || pathname.startsWith(`${props.href}/`)} /> ); return {link}; @@ -34,7 +34,7 @@ const NavLinkHref = (props: NavigationLinkHref) => { const NavLinkWithItems = (props: NavigationLinkWithItems) => { const pathname = usePathname(); - const isActive = props.items.some((item) => item.href === pathname); + const isActive = props.items.some((item) => pathname === item.href || pathname.startsWith(`${item.href}/`)); const nav = ( {props.items.map((item) => ( diff --git a/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx b/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx index f6310d481..d200aa966 100644 --- a/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx +++ b/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx @@ -58,19 +58,23 @@ export const DynamicBreadcrumb = ({ } const href = `/${pathnameParts.slice(0, index + 1).join("/")}`; const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`; + const mappedValue = dynamicMappings?.get(pathnamePart); + + if (mappedValue) { + if (nonInteractable?.includes(pathnamePart)) { + return {mappedValue}; + } + return ( + + {mappedValue} + + ); + } if (nonInteractable?.includes(pathnamePart)) { return {t(`${translationKey}.label` as TranslationKeys)}; } - if (dynamicMappings?.has(pathnamePart)) { - return ( - - {dynamicMappings.get(pathnamePart)} - - ); - } - return ( {t(`${translationKey}.label` as TranslationKeys)} diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index 4527482f0..884998749 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 } from "@homarr/request-handler/docker"; +import { + dockerContainersRequestHandler, + getContainerLogsAsync, + streamContainerLogsAsync, +} from "@homarr/request-handler/docker"; import { dockerMiddleware } from "../../middlewares/docker"; import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc"; @@ -104,6 +108,84 @@ export const dockerRouter = createTRPCRouter({ const innerHandler = dockerContainersRequestHandler.handler({}); await innerHandler.invalidateAsync(); }), + logs: permissionRequiredProcedure + .requiresPermission("admin") + .concat(dockerMiddleware()) + .input( + z.object({ + id: z.string(), + tail: z.number().min(1).max(1000).optional(), + }), + ) + .query(async ({ input }) => { + const logs = await getContainerLogsAsync(input.id, input.tail ?? 200); + + if (!logs) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Container not found", + }); + } + + return { + 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 cleanupFn: (() => void) | undefined; + let isSubscribed = true; + + const initializeStreamAsync = 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 initializeStreamAsync(); + + return () => { + isSubscribed = false; + cleanupFn?.(); + }; + }); + }), }); const getContainerOrDefaultAsync = async (instance: Docker, id: string) => { 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 f50bd32c7..e98695278 100644 --- a/packages/docker/src/shared.ts +++ b/packages/docker/src/shared.ts @@ -11,3 +11,23 @@ export const containerStateColorMap = { removing: "pink", dead: "dark", } satisfies Record; + +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; 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 94633a30a..2d228a598 100644 --- a/packages/request-handler/src/docker.ts +++ b/packages/request-handler/src/docker.ts @@ -1,5 +1,6 @@ +import type { Readable } from "node:stream"; import dayjs from "dayjs"; -import type { ContainerInfo, ContainerStats } from "dockerode"; +import type { Container, ContainerInfo, ContainerStats } from "dockerode"; import type Dockerode from "dockerode"; import { bestMatch } from "@homarr/common"; @@ -11,6 +12,7 @@ import { extractContainerImageName } from "@homarr/definitions"; import type { ContainerState, Port } from "@homarr/docker"; import { dockerLabels, DockerSingleton } from "@homarr/docker"; +import { createDockerLogStreamProcessor, decodeDockerLogs } from "./docker-log-decode"; import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; const logger = createLogger({ module: "dockerRequestHandler" }); @@ -183,6 +185,84 @@ export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({ const extractImage = (container: ContainerInfo) => extractContainerImageName(container.Image); +const findContainerByIdAsync = async (id: string) => { + const dockerInstances = DockerSingleton.getInstances(); + const containers = await Promise.all( + dockerInstances.map(async ({ instance }) => { + const container = instance.getContainer(id); + + return await new Promise((resolve) => { + container.inspect((err, data) => { + if (err || !data) { + resolve(null); + } else { + resolve(container); + } + }); + }); + }), + ); + + return containers.find((container) => container) ?? null; +}; + +export const getContainerLogsAsync = async (id: string, tail = 200) => { + const container = await findContainerByIdAsync(id); + if (!container) { + return null; + } + + const rawLogs = await container.logs({ + tail, + stdout: true, + stderr: true, + follow: false, + }); + + 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, + })) as Readable; + + const MAX_MESSAGE_SIZE = 1024 * 1024; + const processChunk = createDockerLogStreamProcessor(onData, onError, MAX_MESSAGE_SIZE); + + const handleChunk = (chunk: Buffer) => { + const shouldContinue = processChunk(chunk); + if (!shouldContinue) { + stream.removeListener("data", handleChunk); + stream.removeListener("error", onError); + stream.destroy(); + } + }; + + stream.on("data", handleChunk); + stream.on("error", onError); + + return () => { + stream.removeListener("data", handleChunk); + stream.removeListener("error", onError); + stream.destroy(); + }; +}; + async function getContainersWithStatsAsync() { const dockerInstances = DockerSingleton.getInstances(); const results = await Promise.allSettled( @@ -275,8 +355,6 @@ export function calculateMemoryUsage(stats: ContainerStats): number { return 0; } - // memory usage by default includes cache, which should not be shown as it is also not shown with docker stats command - // See https://docs.docker.com/reference/cli/docker/container/stats/ how it is / was calculated return ( stats.memory_stats.usage - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 0dd6f72ef..2a2de758b 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -4123,6 +4123,12 @@ }, "action": { "title": "Actions", + "logs": { + "label": "View logs", + "page": { + "title": "{name} logs" + } + }, "start": { "label": "Start", "notification": { @@ -4535,7 +4541,10 @@ "label": "Tasks" }, "docker": { - "label": "Docker" + "label": "Docker", + "logs": { + "label": "Logs" + } }, "kubernetes": { "label": "Kubernetes", diff --git a/packages/widgets/src/docker/component.tsx b/packages/widgets/src/docker/component.tsx index e47c77f33..5d869c52a 100644 --- a/packages/widgets/src/docker/component.tsx +++ b/packages/widgets/src/docker/component.tsx @@ -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)",