feat(docker): add container logs modal and stats columns (#4807)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Thomasandcopilot-swe-agent[bot] authored and GitHub committed 2026-06-05 16:57:26 +02:00
1 parent cc24f070db
commit dbf021e04a
16 files changed
+666 -65

No files matched your search

+81 -3
View File
@@ -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<Container | null>((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