mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-26 14:01:16 -07:00
Performance: fetch all docker container statuses in one request (#7092)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
@@ -60,6 +60,46 @@ export async function servicesFromConfig() {
|
||||
return parseServicesToGroups(services);
|
||||
}
|
||||
|
||||
function flattenServices(groups, services = []) {
|
||||
groups.forEach((group) => {
|
||||
(group.services ?? []).forEach((service) => services.push(service));
|
||||
flattenServices(group.groups ?? [], services);
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
function dockerWidgets(service) {
|
||||
const widgets = service.widget ? [service.widget, ...(service.widgets ?? [])] : (service.widgets ?? []);
|
||||
return widgets.filter((widget) => widget?.type === "docker");
|
||||
}
|
||||
|
||||
export async function containersFromConfig(server) {
|
||||
const target = server || "";
|
||||
const services = flattenServices(await servicesFromConfig());
|
||||
// services and docker widgets both carry container + server
|
||||
const refs = services.flatMap((service) => [service, ...dockerWidgets(service)]);
|
||||
const matching = refs.filter((ref) => ref.container && (ref.server || "") === target);
|
||||
|
||||
return new Set(matching.map((ref) => ref.container));
|
||||
}
|
||||
|
||||
// homepage.foo -> foo, homepage.instance.<this instance>.foo -> foo, another instance -> null
|
||||
export function homepageLabelValue(label, instanceName) {
|
||||
if (!label.startsWith("homepage.")) return null;
|
||||
|
||||
const value = label.replace("homepage.", "");
|
||||
if (!value.startsWith("instance.")) return value;
|
||||
if (instanceName && value.startsWith(`instance.${instanceName}.`)) {
|
||||
return value.replace(`instance.${instanceName}.`, "");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasHomepageLabels(labels, instanceName) {
|
||||
return Object.keys(labels ?? {}).some((label) => homepageLabelValue(label, instanceName) !== null);
|
||||
}
|
||||
|
||||
export async function servicesFromDocker() {
|
||||
checkAndCopyConfig("docker.yaml");
|
||||
|
||||
@@ -95,29 +135,23 @@ export async function servicesFromDocker() {
|
||||
const containerLabels = isSwarm ? shvl.get(container, "Spec.Labels") : container.Labels;
|
||||
const containerName = isSwarm ? shvl.get(container, "Spec.Name") : container.Names[0];
|
||||
|
||||
Object.keys(containerLabels).forEach((label) => {
|
||||
if (label.startsWith("homepage.")) {
|
||||
let value = label.replace("homepage.", "");
|
||||
if (instanceName && value.startsWith(`instance.${instanceName}.`)) {
|
||||
value = value.replace(`instance.${instanceName}.`, "");
|
||||
} else if (value.startsWith("instance.")) {
|
||||
return;
|
||||
}
|
||||
Object.keys(containerLabels ?? {}).forEach((label) => {
|
||||
const value = homepageLabelValue(label, instanceName);
|
||||
if (value === null) return;
|
||||
|
||||
if (!constructedService) {
|
||||
constructedService = {
|
||||
container: containerName.replace(/^\//, ""),
|
||||
server: serverName,
|
||||
weight: 0,
|
||||
type: "service",
|
||||
};
|
||||
}
|
||||
let substitutedVal = substituteEnvironmentVars(containerLabels[label]);
|
||||
if (value === "widget.version" || /^widgets\[\d+\]\.version$/.test(value)) {
|
||||
substitutedVal = parseVersionForUrl(substitutedVal);
|
||||
}
|
||||
shvl.set(constructedService, value, substitutedVal);
|
||||
if (!constructedService) {
|
||||
constructedService = {
|
||||
container: containerName.replace(/^\//, ""),
|
||||
server: serverName,
|
||||
weight: 0,
|
||||
type: "service",
|
||||
};
|
||||
}
|
||||
let substitutedVal = substituteEnvironmentVars(containerLabels[label]);
|
||||
if (value === "widget.version" || /^widgets\[\d+\]\.version$/.test(value)) {
|
||||
substitutedVal = parseVersionForUrl(substitutedVal);
|
||||
}
|
||||
shvl.set(constructedService, value, substitutedVal);
|
||||
});
|
||||
|
||||
if (constructedService && (!constructedService.name || !constructedService.group)) {
|
||||
|
||||
@@ -103,6 +103,119 @@ describe("utils/config/service-helpers", () => {
|
||||
expect(await mod.servicesFromConfig()).toEqual([]);
|
||||
});
|
||||
|
||||
it("containersFromConfig returns an empty set when services.yaml is empty", async () => {
|
||||
state.servicesYaml = null;
|
||||
|
||||
const mod = await import("./service-helpers");
|
||||
expect(await mod.containersFromConfig("local")).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("containersFromConfig only returns containers for the requested server", async () => {
|
||||
state.servicesYaml = [
|
||||
{ Group: [{ App: { container: "app", server: "local" } }, { Other: { container: "other", server: "remote" } }] },
|
||||
];
|
||||
|
||||
const mod = await import("./service-helpers");
|
||||
expect(await mod.containersFromConfig("local")).toEqual(new Set(["app"]));
|
||||
expect(await mod.containersFromConfig("remote")).toEqual(new Set(["other"]));
|
||||
});
|
||||
|
||||
it("containersFromConfig maps a service without a server to the default connection", async () => {
|
||||
state.servicesYaml = [{ Group: [{ App: { container: "app" } }] }];
|
||||
|
||||
const mod = await import("./service-helpers");
|
||||
expect(await mod.containersFromConfig("")).toEqual(new Set(["app"]));
|
||||
expect(await mod.containersFromConfig(undefined)).toEqual(new Set(["app"]));
|
||||
expect(await mod.containersFromConfig("local")).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("containersFromConfig recurses into nested groups", async () => {
|
||||
state.servicesYaml = [
|
||||
{
|
||||
Main: [
|
||||
{ Child: [{ Deep: { container: "deep", server: "local" } }] },
|
||||
{ Root: { container: "top", server: "local" } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const mod = await import("./service-helpers");
|
||||
expect(await mod.containersFromConfig("local")).toEqual(new Set(["top", "deep"]));
|
||||
});
|
||||
|
||||
it("containersFromConfig includes docker widget containers in both widget and widgets forms", async () => {
|
||||
state.servicesYaml = [
|
||||
{
|
||||
Group: [
|
||||
{ WidgetOnly: { widget: { type: "docker", container: "widgetapp", server: "local" } } },
|
||||
{
|
||||
WithArray: {
|
||||
container: "app",
|
||||
server: "local",
|
||||
widgets: [
|
||||
{ type: "docker", container: "sidecar", server: "local" },
|
||||
{ type: "docker", container: "elsewhere", server: "remote" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const mod = await import("./service-helpers");
|
||||
expect(await mod.containersFromConfig("local")).toEqual(new Set(["widgetapp", "app", "sidecar"]));
|
||||
});
|
||||
|
||||
it("containersFromConfig ignores non docker widgets that carry a container field", async () => {
|
||||
state.servicesYaml = [
|
||||
{ Group: [{ App: { widget: { type: "portainer", container: "sneaky", server: "local" } } }] },
|
||||
];
|
||||
|
||||
const mod = await import("./service-helpers");
|
||||
expect(await mod.containersFromConfig("local")).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("homepageLabelValue strips the prefix and honors instance scoping", async () => {
|
||||
const mod = await import("./service-helpers");
|
||||
|
||||
expect(mod.homepageLabelValue("com.docker.compose.project", "foo")).toBeNull();
|
||||
expect(mod.homepageLabelValue("homepage.name", undefined)).toBe("name");
|
||||
expect(mod.homepageLabelValue("homepage.instance.foo.name", "foo")).toBe("name");
|
||||
expect(mod.homepageLabelValue("homepage.instance.bar.name", "foo")).toBeNull();
|
||||
expect(mod.homepageLabelValue("homepage.instance.bar.name", undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("hasHomepageLabels reports whether a container opts in for this instance", async () => {
|
||||
const mod = await import("./service-helpers");
|
||||
|
||||
expect(mod.hasHomepageLabels(undefined, "foo")).toBe(false);
|
||||
expect(mod.hasHomepageLabels({}, "foo")).toBe(false);
|
||||
expect(mod.hasHomepageLabels({ "com.docker.compose.project": "x" }, "foo")).toBe(false);
|
||||
expect(mod.hasHomepageLabels({ "homepage.name": "X" }, undefined)).toBe(true);
|
||||
expect(mod.hasHomepageLabels({ "homepage.instance.foo.name": "X" }, "foo")).toBe(true);
|
||||
expect(mod.hasHomepageLabels({ "homepage.instance.bar.name": "X" }, "foo")).toBe(false);
|
||||
});
|
||||
|
||||
it("servicesFromDocker skips containers without labels instead of failing the whole server", async () => {
|
||||
state.dockerYaml = { "docker-local": {} };
|
||||
state.dockerContainersByServer["docker-local"] = [
|
||||
{ Names: ["/nolabels"] },
|
||||
{ Names: ["/labelled"], Labels: { "homepage.group": "G", "homepage.name": "Svc" } },
|
||||
];
|
||||
|
||||
const mod = await import("./service-helpers");
|
||||
const discovered = await mod.servicesFromDocker();
|
||||
|
||||
expect(discovered).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "G",
|
||||
services: [expect.objectContaining({ name: "Svc", container: "labelled" })],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("servicesFromDocker returns [] when docker.yaml is empty", async () => {
|
||||
state.dockerYaml = null;
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import Docker from "dockerode";
|
||||
|
||||
import { getSettings } from "utils/config/config";
|
||||
import getDockerArguments from "utils/config/docker";
|
||||
import { containersFromConfig, hasHomepageLabels } from "utils/config/service-helpers";
|
||||
|
||||
const HEALTH_STATES = ["healthy", "unhealthy", "starting"];
|
||||
|
||||
function statusEntry(state, health) {
|
||||
return health ? { status: state, health } : { status: state };
|
||||
}
|
||||
|
||||
// docker exposes health only as a filter on the list endpoint, never as a field
|
||||
async function healthByContainerId(docker) {
|
||||
const results = await Promise.all(
|
||||
HEALTH_STATES.map((state) => docker.listContainers({ all: true, filters: { health: [state] } }).catch(() => [])),
|
||||
);
|
||||
|
||||
const health = {};
|
||||
results.forEach((containers, index) => {
|
||||
if (!Array.isArray(containers)) return;
|
||||
containers.forEach((container) => {
|
||||
health[container.Id] = HEALTH_STATES[index];
|
||||
});
|
||||
});
|
||||
|
||||
return health;
|
||||
}
|
||||
|
||||
export async function getDockerStatuses(server) {
|
||||
const dockerArgs = getDockerArguments(server);
|
||||
const docker = new Docker(dockerArgs.conn);
|
||||
|
||||
const [containers, health, configured] = await Promise.all([
|
||||
docker.listContainers({ all: true }),
|
||||
healthByContainerId(docker),
|
||||
containersFromConfig(server),
|
||||
]);
|
||||
|
||||
if (!Array.isArray(containers)) {
|
||||
return { error: "query failed" };
|
||||
}
|
||||
|
||||
const { instanceName } = getSettings();
|
||||
const statuses = {};
|
||||
const byId = {};
|
||||
|
||||
containers.forEach((container) => {
|
||||
const info = statusEntry(container.State, health[container.Id]);
|
||||
// keyed by id for every container so swarm tasks can resolve their local container
|
||||
byId[container.Id] = info;
|
||||
|
||||
const labelled = hasHomepageLabels(container.Labels, instanceName);
|
||||
container.Names.forEach((name) => {
|
||||
const containerName = name.replace(/^\//, "");
|
||||
if (labelled || configured.has(containerName)) statuses[containerName] = info;
|
||||
});
|
||||
});
|
||||
|
||||
if (!dockerArgs.swarm) {
|
||||
return { statuses };
|
||||
}
|
||||
|
||||
const [services, tasks] = await Promise.all([
|
||||
docker.listServices().catch(() => []),
|
||||
docker.listTasks({ filters: { "desired-state": ["running"] } }).catch(() => []),
|
||||
]);
|
||||
|
||||
const localIds = new Set(containers.map((container) => container.Id));
|
||||
const tasksByService = {};
|
||||
tasks.forEach((task) => {
|
||||
(tasksByService[task.ServiceID] ??= []).push(task);
|
||||
});
|
||||
|
||||
services.forEach((service) => {
|
||||
const name = service.Spec?.Name;
|
||||
if (!name || statuses[name]) return;
|
||||
if (!configured.has(name) && !hasHomepageLabels(service.Spec?.Labels, instanceName)) return;
|
||||
|
||||
const serviceTasks = tasksByService[service.ID] ?? [];
|
||||
|
||||
if (service.Spec.Mode?.Replicated) {
|
||||
const replicas = parseInt(service.Spec.Mode.Replicated.Replicas, 10);
|
||||
if (serviceTasks.length === replicas) {
|
||||
statuses[name] = { status: `running ${serviceTasks.length}/${replicas}` };
|
||||
} else if (serviceTasks.length > 0) {
|
||||
statuses[name] = { status: `partial ${serviceTasks.length}/${replicas}` };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const task =
|
||||
serviceTasks.find((candidate) => localIds.has(candidate.Status?.ContainerStatus?.ContainerID)) ??
|
||||
serviceTasks.at(0);
|
||||
const containerId = task?.Status?.ContainerStatus?.ContainerID;
|
||||
if (containerId && byId[containerId]) {
|
||||
statuses[name] = byId[containerId];
|
||||
} else if (task) {
|
||||
statuses[name] = { status: task.Status.State };
|
||||
}
|
||||
});
|
||||
|
||||
return { statuses };
|
||||
}
|
||||
Reference in New Issue
Block a user