diff --git a/src/__tests__/pages/api/docker/status/[...service].test.js b/src/__tests__/pages/api/docker/status/[...service].test.js deleted file mode 100644 index 50d9e4199..000000000 --- a/src/__tests__/pages/api/docker/status/[...service].test.js +++ /dev/null @@ -1,211 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import createMockRes from "test-utils/create-mock-res"; - -const { state, DockerCtor, getDockerArguments, logger } = vi.hoisted(() => { - const state = { - docker: null, - dockerCtorArgs: [], - dockerArgs: { conn: { socketPath: "/var/run/docker.sock" }, swarm: false }, - }; - - function DockerCtor(conn) { - state.dockerCtorArgs.push(conn); - return state.docker; - } - - return { - state, - DockerCtor, - getDockerArguments: vi.fn(() => state.dockerArgs), - logger: { error: vi.fn() }, - }; -}); - -vi.mock("dockerode", () => ({ - default: DockerCtor, -})); - -vi.mock("utils/config/docker", () => ({ - default: getDockerArguments, -})); - -vi.mock("utils/logger", () => ({ - default: () => logger, -})); - -import handler from "pages/api/docker/status/[...service]"; - -describe("pages/api/docker/status/[...service]", () => { - beforeEach(() => { - vi.clearAllMocks(); - state.dockerCtorArgs.length = 0; - state.dockerArgs = { conn: { socketPath: "/var/run/docker.sock" }, swarm: false }; - state.docker = { - listContainers: vi.fn(), - getContainer: vi.fn(), - getService: vi.fn(), - listTasks: vi.fn(), - }; - }); - - it("returns 400 when container name/server params are missing", async () => { - const req = { query: { service: [] } }; - const res = createMockRes(); - - await handler(req, res); - - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ error: "docker query parameters are required" }); - }); - - it("returns 500 when docker returns a non-array containers payload", async () => { - state.docker.listContainers.mockResolvedValue(Buffer.from("bad")); - - const req = { query: { service: ["c", "local"] } }; - const res = createMockRes(); - - await handler(req, res); - - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: "query failed" }); - }); - - it("inspects an existing container and returns status + health", async () => { - state.docker.listContainers.mockResolvedValue([{ Names: ["/myapp"], Id: "cid1" }]); - state.docker.getContainer.mockReturnValue({ - inspect: vi.fn().mockResolvedValue({ State: { Status: "running", Health: { Status: "healthy" } } }), - }); - - const req = { query: { service: ["myapp", "local"] } }; - const res = createMockRes(); - - await handler(req, res); - - expect(getDockerArguments).toHaveBeenCalledWith("local"); - expect(state.dockerCtorArgs).toHaveLength(1); - expect(res.statusCode).toBe(200); - expect(res.body).toEqual({ status: "running", health: "healthy" }); - }); - - it("returns 404 when container does not exist and swarm is disabled", async () => { - state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "cid1" }]); - - const req = { query: { service: ["missing", "local"] } }; - const res = createMockRes(); - - await handler(req, res); - - expect(res.statusCode).toBe(404); - expect(res.body).toEqual({ status: "not found" }); - }); - - it("reports replicated swarm service status based on desired replicas", async () => { - state.dockerArgs.swarm = true; - state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "cid1" }]); - state.docker.getService.mockReturnValue({ - inspect: vi.fn().mockResolvedValue({ Spec: { Mode: { Replicated: { Replicas: "2" } } } }), - }); - state.docker.listTasks.mockResolvedValue([{ Status: {} }, { Status: {} }]); - - const req = { query: { service: ["svc", "local"] } }; - const res = createMockRes(); - - await handler(req, res); - - expect(res.statusCode).toBe(200); - expect(res.body).toEqual({ status: "running 2/2" }); - }); - - it("reports partial status for replicated services with fewer running tasks", async () => { - state.dockerArgs.swarm = true; - state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "cid1" }]); - state.docker.getService.mockReturnValue({ - inspect: vi.fn().mockResolvedValue({ Spec: { Mode: { Replicated: { Replicas: "3" } } } }), - }); - state.docker.listTasks.mockResolvedValue([{ Status: {} }]); - - const req = { query: { service: ["svc", "local"] } }; - const res = createMockRes(); - - await handler(req, res); - - expect(res.statusCode).toBe(200); - expect(res.body).toEqual({ status: "partial 1/3" }); - }); - - it("handles global services by inspecting a local task container when possible", async () => { - state.dockerArgs.swarm = true; - state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "local1" }]); - state.docker.getService.mockReturnValue({ - inspect: vi.fn().mockResolvedValue({ Spec: { Mode: { Global: {} } } }), - }); - state.docker.listTasks.mockResolvedValue([ - { Status: { ContainerStatus: { ContainerID: "local1" }, State: "running" } }, - ]); - state.docker.getContainer.mockReturnValue({ - inspect: vi.fn().mockResolvedValue({ State: { Status: "running", Health: { Status: "unhealthy" } } }), - }); - - const req = { query: { service: ["svc", "local"] } }; - const res = createMockRes(); - - await handler(req, res); - - expect(res.statusCode).toBe(200); - expect(res.body).toEqual({ status: "running", health: "unhealthy" }); - }); - - it("falls back to task status when global service container inspect fails", async () => { - state.dockerArgs.swarm = true; - state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "local1" }]); - state.docker.getService.mockReturnValue({ - inspect: vi.fn().mockResolvedValue({ Spec: { Mode: { Global: {} } } }), - }); - state.docker.listTasks.mockResolvedValue([ - { Status: { ContainerStatus: { ContainerID: "local1" }, State: "pending" } }, - ]); - state.docker.getContainer.mockReturnValue({ - inspect: vi.fn().mockRejectedValue(new Error("nope")), - }); - - const req = { query: { service: ["svc", "local"] } }; - const res = createMockRes(); - - await handler(req, res); - - expect(res.statusCode).toBe(200); - expect(res.body).toEqual({ status: "pending" }); - }); - - it("returns 404 when swarm is enabled but the service does not exist", async () => { - state.dockerArgs.swarm = true; - state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "cid1" }]); - state.docker.getService.mockReturnValue({ - inspect: vi.fn().mockRejectedValue(new Error("not found")), - }); - - const req = { query: { service: ["svc", "local"] } }; - const res = createMockRes(); - - await handler(req, res); - - expect(res.statusCode).toBe(404); - expect(res.body).toEqual({ status: "not found" }); - }); - - it("logs and returns 500 when the docker query throws", async () => { - getDockerArguments.mockImplementationOnce(() => { - throw new Error("boom"); - }); - - const req = { query: { service: ["svc", "local"] } }; - const res = createMockRes(); - - await handler(req, res); - - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: { message: "boom" } }); - expect(logger.error).toHaveBeenCalled(); - }); -}); diff --git a/src/__tests__/pages/api/docker/statuses.test.js b/src/__tests__/pages/api/docker/statuses.test.js new file mode 100644 index 000000000..0a45a11e8 --- /dev/null +++ b/src/__tests__/pages/api/docker/statuses.test.js @@ -0,0 +1,265 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { state, DockerCtor, getDockerArguments, containersFromConfig, hasHomepageLabels, getSettings, logger } = + vi.hoisted(() => { + const state = { + docker: null, + containers: [], + health: {}, + containers: [], + health: {}, + dockerArgs: { conn: { socketPath: "/var/run/docker.sock" }, swarm: false }, + }; + + function DockerCtor() { + return state.docker; + } + + return { + state, + DockerCtor, + getDockerArguments: vi.fn(() => state.dockerArgs), + containersFromConfig: vi.fn(async () => new Set()), + hasHomepageLabels: vi.fn(() => false), + getSettings: vi.fn(() => ({ instanceName: undefined })), + logger: { error: vi.fn() }, + }; + }); + +vi.mock("dockerode", () => ({ + default: DockerCtor, +})); + +vi.mock("utils/config/docker", () => ({ + default: getDockerArguments, +})); + +vi.mock("utils/config/service-helpers", () => ({ + containersFromConfig, + hasHomepageLabels, +})); + +vi.mock("utils/config/config", () => ({ + getSettings, +})); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +import handler from "pages/api/docker/statuses"; + +describe("pages/api/docker/statuses", () => { + beforeEach(() => { + vi.clearAllMocks(); + state.dockerArgs = { conn: { socketPath: "/var/run/docker.sock" }, swarm: false }; + state.containers = []; + state.health = {}; + state.docker = { + listContainers: vi.fn(async (options) => { + const health = options?.filters?.health?.[0]; + return health ? (state.health[health] ?? []) : state.containers; + }), + listServices: vi.fn(), + listTasks: vi.fn(), + }; + containersFromConfig.mockResolvedValue(new Set()); + hasHomepageLabels.mockReturnValue(false); + getSettings.mockReturnValue({ instanceName: undefined }); + }); + + it("returns configured container statuses with health from the docker health filters", async () => { + containersFromConfig.mockResolvedValue(new Set(["glance", "share"])); + state.containers = [ + { Names: ["/glance"], Id: "cid-glance", State: "running" }, + { Names: ["/share"], Id: "cid-share", State: "exited" }, + ]; + state.health = { healthy: [{ Id: "cid-glance" }] }; + + const req = { query: { server: "local" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(getDockerArguments).toHaveBeenCalledWith("local"); + expect(containersFromConfig).toHaveBeenCalledWith("local"); + // one list plus one per health state, constant regardless of container count + expect(state.docker.listContainers).toHaveBeenCalledTimes(4); + expect(state.docker.listServices).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ + statuses: { + glance: { status: "running", health: "healthy" }, + share: { status: "exited" }, + }, + }); + }); + + it("takes health from the filter results rather than the status string", async () => { + containersFromConfig.mockResolvedValue(new Set(["app"])); + state.containers = [{ Names: ["/app"], Id: "cid1", State: "running", Status: "Up 2 hours (healthy)" }]; + state.health = { unhealthy: [{ Id: "cid1" }] }; + + const req = { query: { server: "local" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.body).toEqual({ statuses: { app: { status: "running", health: "unhealthy" } } }); + }); + + it("omits health when the docker daemon rejects the health filter", async () => { + containersFromConfig.mockResolvedValue(new Set(["app"])); + state.containers = [{ Names: ["/app"], Id: "cid1", State: "running", Status: "Up 2 hours (healthy)" }]; + state.docker.listContainers.mockImplementation(async (options) => { + if (options?.filters?.health) throw new Error("filter unsupported"); + return state.containers; + }); + + const req = { query: { server: "local" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.body).toEqual({ statuses: { app: { status: "running" } } }); + }); + + it("omits containers that are neither configured nor labelled for homepage", async () => { + containersFromConfig.mockResolvedValue(new Set(["glance"])); + state.containers = [ + { Names: ["/glance"], State: "running", Status: "Up" }, + { Names: ["/secret-db"], State: "running", Status: "Up" }, + ]; + + const req = { query: { server: "local" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.body).toEqual({ statuses: { glance: { status: "running" } } }); + expect(res.body.statuses["secret-db"]).toBeUndefined(); + }); + + it("includes containers discovered through homepage labels", async () => { + hasHomepageLabels.mockImplementation((labels) => labels?.["homepage.name"] !== undefined); + state.containers = [ + { Names: ["/labelled"], State: "running", Status: "Up", Labels: { "homepage.name": "App" } }, + { Names: ["/unlabelled"], State: "running", Status: "Up", Labels: {} }, + ]; + + const req = { query: { server: "local" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.body).toEqual({ statuses: { labelled: { status: "running" } } }); + }); + + it("includes swarm services when the server is in swarm mode", async () => { + state.dockerArgs.swarm = true; + containersFromConfig.mockResolvedValue(new Set(["web", "api"])); + state.containers = [{ Names: ["/web"], Id: "cid1", State: "running", Status: "Up" }]; + state.docker.listServices.mockResolvedValue([ + { ID: "sid", Spec: { Name: "api", Mode: { Replicated: { Replicas: "2" } } } }, + ]); + state.docker.listTasks.mockResolvedValue([ + { ServiceID: "sid", Status: {} }, + { ServiceID: "sid", Status: {} }, + ]); + + const req = { query: { server: "swarm" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ + statuses: { + web: { status: "running" }, + api: { status: "running 2/2" }, + }, + }); + }); + + it("omits swarm services that are neither configured nor labelled", async () => { + state.dockerArgs.swarm = true; + containersFromConfig.mockResolvedValue(new Set()); + state.containers = []; + state.docker.listServices.mockResolvedValue([ + { ID: "sid", Spec: { Name: "internal", Mode: { Replicated: { Replicas: "1" } } } }, + ]); + state.docker.listTasks.mockResolvedValue([{ ServiceID: "sid", Status: {} }]); + + const req = { query: { server: "swarm" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.body).toEqual({ statuses: {} }); + }); + + it("includes swarm services discovered through homepage labels", async () => { + state.dockerArgs.swarm = true; + hasHomepageLabels.mockImplementation((labels) => labels?.["homepage.name"] !== undefined); + state.containers = []; + state.docker.listServices.mockResolvedValue([ + { + ID: "sid", + Spec: { Name: "api", Labels: { "homepage.name": "Api" }, Mode: { Replicated: { Replicas: "1" } } }, + }, + ]); + state.docker.listTasks.mockResolvedValue([{ ServiceID: "sid", Status: {} }]); + + const req = { query: { server: "swarm" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.body).toEqual({ statuses: { api: { status: "running 1/1" } } }); + }); + + it("returns only listed containers when swarm queries fail", async () => { + state.dockerArgs.swarm = true; + containersFromConfig.mockResolvedValue(new Set(["web"])); + state.containers = [{ Names: ["/web"], Id: "cid1", State: "running", Status: "Up" }]; + state.docker.listServices.mockRejectedValue(new Error("no services")); + state.docker.listTasks.mockRejectedValue(new Error("no tasks")); + + const req = { query: { server: "swarm" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ statuses: { web: { status: "running" } } }); + }); + + it("returns 500 when docker returns a non-array containers payload", async () => { + state.containers = Buffer.from("bad"); + + const req = { query: { server: "local" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(500); + expect(res.body).toEqual({ error: "query failed" }); + }); + + it("logs and returns 500 when the docker query throws", async () => { + getDockerArguments.mockImplementationOnce(() => { + throw new Error("boom"); + }); + + const req = { query: { server: "local" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(500); + expect(res.body).toEqual({ error: { message: "boom" } }); + expect(logger.error).toHaveBeenCalled(); + }); +}); diff --git a/src/components/services/status.jsx b/src/components/services/status.jsx index b94d0cab1..88ff9096f 100644 --- a/src/components/services/status.jsx +++ b/src/components/services/status.jsx @@ -4,13 +4,16 @@ import useSWR from "swr"; export default function Status({ service, style }) { const { t } = useTranslation(); - const { data, error } = useSWR(`/api/docker/status/${service.container}/${service.server || ""}`); + const { data: response, error } = useSWR(`/api/docker/statuses?server=${encodeURIComponent(service.server || "")}`); + const statusError = error ?? response?.error; + const { statuses } = response ?? {}; + const data = statuses ? (statuses[service.container] ?? { status: "not found" }) : undefined; let statusLabel = t("docker.unknown"); let backgroundClass = "px-1.5 py-0.5 bg-theme-500/10 dark:bg-theme-900/50"; let colorClass = "text-black/20 dark:text-white/40 "; - if (error) { + if (statusError) { statusLabel = t("docker.error"); colorClass = "text-rose-500/80"; } else if (data) { diff --git a/src/components/services/status.test.jsx b/src/components/services/status.test.jsx index fc07a6bfa..54364f78c 100644 --- a/src/components/services/status.test.jsx +++ b/src/components/services/status.test.jsx @@ -21,8 +21,11 @@ describe("components/services/status", () => { render(); - expect(useSWR).toHaveBeenCalledWith("/api/docker/status/c/s"); + expect(useSWR).toHaveBeenCalledWith("/api/docker/statuses?server=s"); expect(screen.getByText("docker.unknown")).toBeInTheDocument(); + + render(); + expect(useSWR).toHaveBeenCalledWith("/api/docker/statuses?server="); }); it("renders error when SWR fails", () => { @@ -33,30 +36,39 @@ describe("components/services/status", () => { expect(screen.getByText("docker.error")).toBeInTheDocument(); }); + it("renders error when the api returns an error payload with a 200-parsed body", () => { + useSWR.mockReturnValue({ data: { error: "query failed" }, error: undefined }); + + render(); + + expect(screen.getByText("docker.error")).toBeInTheDocument(); + expect(screen.queryByText("docker.not_found")).not.toBeInTheDocument(); + }); + it("renders healthy/unhealthy and partial/exited/not found statuses", () => { - useSWR.mockReturnValue({ data: { status: "running", health: "healthy" }, error: undefined }); + useSWR.mockReturnValue({ data: { statuses: { c: { status: "running", health: "healthy" } } }, error: undefined }); render(); expect(screen.getByText("docker.healthy")).toBeInTheDocument(); - useSWR.mockReturnValue({ data: { status: "running", health: "unhealthy" }, error: undefined }); + useSWR.mockReturnValue({ data: { statuses: { c: { status: "running", health: "unhealthy" } } }, error: undefined }); render(); expect(screen.getByText("docker.unhealthy")).toBeInTheDocument(); - useSWR.mockReturnValue({ data: { status: "partial 1/2" }, error: undefined }); + useSWR.mockReturnValue({ data: { statuses: { c: { status: "partial 1/2" } } }, error: undefined }); render(); expect(screen.getByText("docker.partial 1/2")).toBeInTheDocument(); - useSWR.mockReturnValue({ data: { status: "exited" }, error: undefined }); + useSWR.mockReturnValue({ data: { statuses: { c: { status: "exited" } } }, error: undefined }); render(); expect(screen.getByText("docker.exited")).toBeInTheDocument(); - useSWR.mockReturnValue({ data: { status: "not found" }, error: undefined }); + useSWR.mockReturnValue({ data: { statuses: {} }, error: undefined }); render(); expect(screen.getByText("docker.not_found")).toBeInTheDocument(); }); it("renders starting health when container is running and starting", () => { - useSWR.mockReturnValue({ data: { status: "running", health: "starting" }, error: undefined }); + useSWR.mockReturnValue({ data: { statuses: { c: { status: "running", health: "starting" } } }, error: undefined }); render(); @@ -64,7 +76,7 @@ describe("components/services/status", () => { }); it("renders a dot when style is dot", () => { - useSWR.mockReturnValue({ data: { status: "running" }, error: undefined }); + useSWR.mockReturnValue({ data: { statuses: { c: { status: "running" } } }, error: undefined }); const { container } = render(); diff --git a/src/pages/api/docker/status/[...service].js b/src/pages/api/docker/status/[...service].js deleted file mode 100644 index f9dc640bb..000000000 --- a/src/pages/api/docker/status/[...service].js +++ /dev/null @@ -1,116 +0,0 @@ -import Docker from "dockerode"; - -import getDockerArguments from "utils/config/docker"; -import createLogger from "utils/logger"; - -const logger = createLogger("dockerStatusService"); - -export default async function handler(req, res) { - const { service } = req.query; - const [containerName, containerServer] = service; - - if (!containerName && !containerServer) { - return res.status(400).send({ - error: "docker query parameters are required", - }); - } - - try { - const dockerArgs = getDockerArguments(containerServer); - const docker = new Docker(dockerArgs.conn); - const containers = await docker.listContainers({ - all: true, - }); - - // bad docker connections can result in a object? - // in any case, this ensures the result is the expected array - if (!Array.isArray(containers)) { - return res.status(500).send({ - error: "query failed", - }); - } - - const containerNames = containers.flatMap((container) => container.Names.map((name) => name.replace(/^\//, ""))); - const containerExists = containerNames.includes(containerName); - - if (containerExists) { - const container = docker.getContainer(containerName); - const info = await container.inspect(); - - return res.status(200).json({ - status: info.State.Status, - health: info.State.Health?.Status, - }); - } - - if (dockerArgs.swarm) { - const serviceInfo = await docker - .getService(containerName) - .inspect() - .catch(() => undefined); - - if (!serviceInfo) { - return res.status(404).send({ - status: "not found", - }); - } - - const tasks = await docker - .listTasks({ - filters: { - service: [containerName], - "desired-state": ["running"], - }, - }) - .catch(() => []); - - if (serviceInfo.Spec.Mode?.Replicated) { - // Replicated service, check n replicas - const replicas = parseInt(serviceInfo.Spec.Mode?.Replicated?.Replicas, 10); - if (tasks.length === replicas) { - return res.status(200).json({ - status: `running ${tasks.length}/${replicas}`, - }); - } - if (tasks.length > 0) { - return res.status(200).json({ - status: `partial ${tasks.length}/${replicas}`, - }); - } - } else { - // Global service, prefer 'local' containers - const localContainerIDs = containers.map((c) => c.Id); - const task = - tasks.find((t) => localContainerIDs.includes(t.Status?.ContainerStatus?.ContainerID)) ?? tasks.at(0); - const taskContainerId = task?.Status?.ContainerStatus?.ContainerID; - - if (taskContainerId) { - try { - const container = docker.getContainer(taskContainerId); - const info = await container.inspect(); - - return res.status(200).json({ - status: info.State.Status, - health: info.State.Health?.Status, - }); - } catch (e) { - if (task) { - return res.status(200).json({ - status: task.Status.State, - }); - } - } - } - } - } - - return res.status(404).send({ - status: "not found", - }); - } catch (e) { - if (e) logger.error(e); - return res.status(500).send({ - error: { message: e?.message ?? "Unknown error" }, - }); - } -} diff --git a/src/pages/api/docker/statuses.js b/src/pages/api/docker/statuses.js new file mode 100644 index 000000000..2b3fefc38 --- /dev/null +++ b/src/pages/api/docker/statuses.js @@ -0,0 +1,21 @@ +import { getDockerStatuses } from "utils/docker/status"; +import createLogger from "utils/logger"; + +const logger = createLogger("dockerStatuses"); + +export default async function handler(req, res) { + try { + const result = await getDockerStatuses(req.query.server); + + if (result.error) { + return res.status(500).send({ error: result.error }); + } + + return res.status(200).json(result); + } catch (e) { + if (e) logger.error(e); + return res.status(500).send({ + error: { message: e?.message ?? "Unknown error" }, + }); + } +} diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js index d028d3db3..9a9270d3d 100644 --- a/src/utils/config/service-helpers.js +++ b/src/utils/config/service-helpers.js @@ -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..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)) { diff --git a/src/utils/config/service-helpers.test.js b/src/utils/config/service-helpers.test.js index 1032edc77..7109b9abf 100644 --- a/src/utils/config/service-helpers.test.js +++ b/src/utils/config/service-helpers.test.js @@ -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; diff --git a/src/utils/docker/status.js b/src/utils/docker/status.js new file mode 100644 index 000000000..8001b5b39 --- /dev/null +++ b/src/utils/docker/status.js @@ -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 }; +} diff --git a/src/widgets/docker/component.jsx b/src/widgets/docker/component.jsx index b37e1eff0..75064f6cf 100644 --- a/src/widgets/docker/component.jsx +++ b/src/widgets/docker/component.jsx @@ -11,14 +11,16 @@ export default function Component({ service }) { const { widget } = service; - const { data: statusData, error: statusError } = useSWR( - `/api/docker/status/${widget.container}/${widget.server || ""}`, + const { data: statusResponse, error: statusError } = useSWR( + `/api/docker/statuses?server=${encodeURIComponent(widget.server || "")}`, ); + const { statuses } = statusResponse ?? {}; + const statusData = statuses ? (statuses[widget.container] ?? { status: "not found" }) : undefined; const { data: statsData, error: statsError } = useSWR(`/api/docker/stats/${widget.container}/${widget.server || ""}`); - if (statsError || statsData?.error || statusError || statusData?.error) { - const finalError = statsError ?? statsData?.error ?? statusError ?? statusData?.error; + if (statsError || statsData?.error || statusError || statusResponse?.error) { + const finalError = statsError ?? statsData?.error ?? statusError ?? statusResponse?.error; return ; } diff --git a/src/widgets/docker/component.test.jsx b/src/widgets/docker/component.test.jsx index 9fdffc539..c94a2ce79 100644 --- a/src/widgets/docker/component.test.jsx +++ b/src/widgets/docker/component.test.jsx @@ -20,8 +20,8 @@ describe("widgets/docker/component", () => { it("renders offline status when container is not running", () => { useSWR - .mockReturnValueOnce({ data: { status: "exited" }, error: undefined }) // status - .mockReturnValueOnce({ data: undefined, error: undefined }); // stats + .mockReturnValueOnce({ data: { statuses: { c: { status: "exited" } } }, error: undefined }) + .mockReturnValueOnce({ data: undefined, error: undefined }); renderWithProviders(, { settings: { hideErrors: false }, @@ -31,9 +31,34 @@ describe("widgets/docker/component", () => { expect(screen.getByText("docker.offline")).toBeInTheDocument(); }); + it("surfaces a docker error payload instead of reporting the container offline", () => { + useSWR + .mockReturnValueOnce({ data: { error: { message: "socket unreachable" } }, error: undefined }) + .mockReturnValueOnce({ data: undefined, error: undefined }); + + renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expect(screen.queryByText("docker.offline")).not.toBeInTheDocument(); + }); + + it("treats a missing container in the bulk status map as offline", () => { + useSWR + .mockReturnValueOnce({ data: { statuses: {} }, error: undefined }) + .mockReturnValueOnce({ data: undefined, error: undefined }); + + renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expect(useSWR).toHaveBeenCalledWith("/api/docker/statuses?server=s"); + expect(screen.getByText("docker.offline")).toBeInTheDocument(); + }); + it("renders cpu/mem/rx/tx values when stats are available", () => { useSWR - .mockReturnValueOnce({ data: { status: "running" }, error: undefined }) // status + .mockReturnValueOnce({ data: { statuses: { c: { status: "running" } } }, error: undefined }) .mockReturnValueOnce({ data: { stats: {