Files
homarr/packages/request-handler/src/docker.ts
T
Meier Lukas 7cd30b640c fix(docker): improve icon matching (#5693)
* fix(docker): improve icon matching

* fix: unused import
2026-05-18 21:45:33 +02:00

133 lines
4.8 KiB
TypeScript

import dayjs from "dayjs";
import type { ContainerInfo, ContainerStats } from "dockerode";
import type Dockerode from "dockerode";
import { bestMatch } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { db, like, or } from "@homarr/db";
import { icons } from "@homarr/db/schema";
import type { ContainerState } from "@homarr/docker";
import { dockerLabels, DockerSingleton } from "@homarr/docker";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
const logger = createLogger({ module: "dockerRequestHandler" });
export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
queryKey: "dockerContainersResult",
widgetKind: "dockerContainers",
async requestAsync() {
return await getContainersWithStatsAsync();
},
cacheDuration: dayjs.duration(20, "seconds"),
});
const extractImage = (container: ContainerInfo) => container.Image.split("/").at(-1)?.split(":").at(0) ?? "";
async function getContainersWithStatsAsync() {
const dockerInstances = DockerSingleton.getInstances();
const results = await Promise.allSettled(
dockerInstances.map(async ({ instance, host }) => {
const instanceContainers = await instance.listContainers({ all: true });
return instanceContainers
.filter((container) => !(dockerLabels.hide in container.Labels))
.map((container) => ({ ...container, instance: host }));
}),
);
const containers = results.flatMap((result, index) => {
if (result.status === "fulfilled") return result.value;
logger.warn(
new ErrorWithMetadata(
"Failed to list containers from Docker host",
{
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
host: dockerInstances[index]!.host,
},
{ cause: result.reason },
),
);
return [];
});
const likeQueries = containers.map((container) => like(icons.name, `%${extractImage(container)}%`));
const dbIcons =
likeQueries.length > 0
? await db.query.icons.findMany({
where: or(...likeQueries),
})
: [];
const containerStatsPromises = containers.map(async (container) => {
const instance = dockerInstances.find(({ host }) => host === container.instance)?.instance;
if (!instance) return null;
const stats = await instance
.getContainer(container.Id)
.stats({ stream: false, "one-shot": true })
.catch(
() =>
({
cpu_stats: { online_cpus: 0, cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 },
memory_stats: { usage: 0 },
}) as ContainerStats,
);
const cpuUsage = calculateCpuUsage(stats);
const memoryUsage = calculateMemoryUsage(stats);
return {
id: container.Id,
name: container.Names[0]?.split("/")[1] ?? "Unknown",
host: container.instance,
state: container.State as ContainerState,
iconUrl: bestMatch(extractImage(container), dbIcons, (icon) => icon.name)?.url ?? null,
cpuUsage,
memoryUsage,
image: container.Image,
ports: container.Ports as Dockerode.Port[] | undefined,
};
});
return (await Promise.all(containerStatsPromises)).filter((container) => container !== null);
}
export function calculateCpuUsage(stats: ContainerStats): number {
// Handle containers with missing or invalid stats (e.g., exited, dead containers, Podman responses)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!stats.cpu_stats?.online_cpus || stats.cpu_stats.online_cpus === 0 || !stats.cpu_stats.cpu_usage?.total_usage) {
return 0;
}
const numberOfCpus = stats.cpu_stats.online_cpus;
const usage = stats.cpu_stats.system_cpu_usage;
if (!usage || usage === 0) {
return 0;
}
return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100;
}
export function calculateMemoryUsage(stats: ContainerStats): number {
// Handle containers with missing or invalid stats (e.g., exited, dead containers, Podman responses)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!stats.memory_stats?.usage) {
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
(stats.memory_stats.stats?.cache ??
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
stats.memory_stats.stats?.total_inactive_file ??
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
stats.memory_stats.stats?.inactive_file ??
0)
);
}