From 1ca38a31c93ce8586bbe79a4f55ba5f188af36f2 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 12:24:44 -0700 Subject: [PATCH 01/13] add new docker payload types, promote docker type monitor to first class monitor --- server/src/domain/monitors/monitor.model.ts | 3 + .../monitors/monitor.repository.mongo.ts | 1 + server/src/domain/monitors/monitor.type.ts | 1 + server/src/service/network/DockerProvider.ts | 250 ++++++++++-------- server/src/types/network.ts | 34 ++- 5 files changed, 177 insertions(+), 112 deletions(-) diff --git a/server/src/domain/monitors/monitor.model.ts b/server/src/domain/monitors/monitor.model.ts index 15b277efb..ba803dc67 100644 --- a/server/src/domain/monitors/monitor.model.ts +++ b/server/src/domain/monitors/monitor.model.ts @@ -233,6 +233,9 @@ const MonitorSchema = new Schema( secret: { type: String, }, + sshPrivateKey: { + type: String, + }, cpuAlertThreshold: { type: Number, default: 100, diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index 8f773f983..9617ba3f6 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -436,6 +436,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { tags: tagIds, customUpCodes: doc.customUpCodes ?? [], secret: doc.secret ?? undefined, + sshPrivateKey: doc.sshPrivateKey ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, cpuAlertCounter: doc.cpuAlertCounter, memoryAlertThreshold: doc.memoryAlertThreshold, diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index 59d91ced9..1767ba17f 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -99,6 +99,7 @@ export interface Monitor { tags: string[]; customUpCodes: HttpStatusCode[]; secret?: string; + sshPrivateKey?: string; cpuAlertThreshold: number; cpuAlertCounter: number; memoryAlertThreshold: number; diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index 9f2a99de7..543f73011 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -1,5 +1,14 @@ import { IStatusProvider } from "@/service/network/IStatusProvider.js"; -import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js"; +import { + DockerContainerInfo, + DockerContainerState, + DockerContainerStates, + DockerContainerSummary, + DockerHealthStatus, + DockerHealthStatuses, + DockerStatusPayload, + MonitorStatusResponse, +} from "@/types/network.js"; import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { ILogger } from "@/utils/logger.js"; import { AppError } from "@/utils/AppError.js"; @@ -9,6 +18,7 @@ import { NETWORK_ERROR } from "@/types/network.js"; type DockerodeType = typeof Dockerode; const SERVICE_NAME = "DockerProvider"; +const STATS_CONCURRENCY = 5; export interface DockerError extends Error { statusCode?: number; @@ -18,13 +28,11 @@ export interface DockerError extends Error { export class DockerProvider implements IStatusProvider { readonly type = "docker"; - private docker: Dockerode; + constructor( private logger: ILogger, private DockerLib: DockerodeType - ) { - this.docker = new this.DockerLib(); - } + ) {} supports(type: MonitorType): boolean { return type === "docker"; @@ -34,141 +42,161 @@ export class DockerProvider implements IStatusProvider { return error instanceof Error && ("statusCode" in error || "reason" in error || "json" in error); } - async handle(monitor: Monitor): Promise> { - const { url: containerInput } = monitor; + private toDockerOptions = (monitor: Monitor): Dockerode.DockerOptions => { + const url = monitor.url?.trim(); + if (!url) throw new AppError({ message: "Docker host URL is required", status: 422, service: SERVICE_NAME, method: "toDockerOptions" }); + if (url.startsWith("unix://")) { + const socketPath = url.slice("unix://".length); + if (!socketPath.startsWith("/")) throw new Error(`Invalid Docker host URL: ${url}`); + return { socketPath }; + } + if (url.startsWith("/")) return { socketPath: url }; + const match = url.match(/^ssh:\/\/(?:([^@\s]+)@)?([^\s/:@]+)(?::(\d{1,5}))?\/?$/); + if (!match) throw new AppError({ message: "Invalid Docker host URL", status: 422, service: SERVICE_NAME, method: "toDockerOptions" }); + const [, username, host, port] = match; + if (!username) + throw new AppError({ + message: "SSH Docker host URL requires a user: ssh://user@host", + status: 422, + service: SERVICE_NAME, + method: "ToDockerOptions", + }); + return { + protocol: "ssh", + host, + port: port ? Number(port) : 22, + username, + sshOptions: { privateKey: monitor.sshPrivateKey }, + }; + }; + + private async mapWithConcurrency(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const results: R[] = []; + for (let i = 0; i < items.length; i += limit) { + results.push(...(await Promise.all(items.slice(i, i + limit).map(fn)))); + } + return results; + } + + private toContainerState(state: string): DockerContainerState { + return (DockerContainerStates as readonly string[]).includes(state) ? (state as DockerContainerState) : "created"; + } + + private toHealthStatus(status: string | undefined): DockerHealthStatus { + return status && (DockerHealthStatuses as readonly string[]).includes(status) ? (status as DockerHealthStatus) : "none"; + } + + private computeCpuPct(stats: Dockerode.ContainerStats): number { + const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage; + const systemDelta = stats.cpu_stats.system_cpu_usage - (stats.precpu_stats.system_cpu_usage ?? 0); + const onlineCpus = stats.cpu_stats.online_cpus ?? stats.cpu_stats.cpu_usage.percpu_usage?.length ?? 1; + if (!(systemDelta > 0) || cpuDelta < 0) return 0; + return (cpuDelta / systemDelta) * onlineCpus * 100; + } + + private computeMemory(stats: Dockerode.ContainerStats): Pick { + const raw = stats.memory_stats.usage ?? 0; + const pageCache = stats.memory_stats.stats?.inactive_file ?? stats.memory_stats.stats?.cache ?? 0; + const memoryUsedBytes = Math.max(0, raw - pageCache); + const memoryLimitBytes = stats.memory_stats.limit ?? 0; + return { + memoryUsedBytes, + memoryLimitBytes, + memoryPct: memoryLimitBytes > 0 ? (memoryUsedBytes / memoryLimitBytes) * 100 : 0, + }; + } + + private async toContainerInfo(docker: Dockerode, summary: Dockerode.ContainerInfo): Promise { + const info: DockerContainerInfo = { + id: summary.Id, + name: summary.Names?.[0]?.replace(/^\//, "") ?? summary.Id.slice(0, 12), + image: summary.Image, + state: this.toContainerState(summary.State), + status: summary.Status, + health: "none", + }; + + const container = docker.getContainer(summary.Id); + const [inspectResult, statsResult] = await Promise.allSettled([ + container.inspect(), + summary.State === "running" ? container.stats({ stream: false }) : Promise.resolve(null), + ]); + + if (inspectResult.status === "fulfilled") { + info.restartCount = inspectResult.value.RestartCount; + info.startedAt = inspectResult.value.State?.StartedAt; + info.health = this.toHealthStatus(inspectResult.value.State?.Health?.Status); + } else { + this.logger.warn({ + message: `Failed to inspect container ${info.name}`, + service: SERVICE_NAME, + method: "toContainerInfo", + details: { containerId: summary.Id }, + }); + } + + if (statsResult.status === "fulfilled" && statsResult.value) { + info.cpuPct = this.computeCpuPct(statsResult.value); + Object.assign(info, this.computeMemory(statsResult.value)); + } + + return info; + } + + handle = async (monitor: Monitor): Promise> => { try { - if (!containerInput) { - throw new Error("Container name or ID is required for Docker monitor"); - } - - const containers = await this.docker.listContainers({ all: true }); - const normalizedInput = containerInput.replace(/^\/+/, "").toLowerCase(); - - // Priority-based matching to avoid ambiguity: - // 1. Exact full ID match (64-char) - const exactIdMatch = containers.find((c) => c.Id.toLowerCase() === normalizedInput); - - // 2. Exact container name match (case-insensitive) - const exactNameMatch = containers.find((c) => - c.Names.some((name: string) => { - const cleanName = name.replace(/^\/+/, "").toLowerCase(); - return cleanName === normalizedInput; - }) - ); - - // 3. Partial ID match (fallback for backwards compatibility) - const partialIdMatch = containers.find((c) => c.Id.toLowerCase().startsWith(normalizedInput)); - - // Select container based on priority - const targetContainer = exactIdMatch || exactNameMatch || partialIdMatch; - - // Handle no match - if (!targetContainer) { - this.logger.warn({ - message: `No container found for "${monitor.url}".`, - service: SERVICE_NAME, - method: "handle", - details: { url: monitor.url }, - }); - - return { - monitorId: monitor.id, - teamId: monitor.teamId, - type: monitor.type, - status: false, - code: 404, - message: "Docker container not found", - responseTime: 0, - payload: null, - }; - } - - // 5. Handle Ambiguity Check - const matchTypes: string[] = []; - if (exactIdMatch) matchTypes.push("exact ID"); - if (exactNameMatch) matchTypes.push("exact name"); - if (partialIdMatch && !exactIdMatch) matchTypes.push("partial ID"); - - if (matchTypes.length > 1) { - const matchUsed = exactIdMatch ? "exact ID" : "exact name"; - const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${matchUsed} match.`; - - this.logger.warn({ - message, - service: SERVICE_NAME, - method: "handle", - details: { url: containerInput }, - }); - - return { - monitorId: monitor.id, - teamId: monitor.teamId, - type: monitor.type, - status: false, - code: NETWORK_ERROR, - message, - responseTime: 0, - payload: null, - }; - } - - // 6. Inspect Container Status - const container = this.docker.getContainer(targetContainer.Id); - const { response, responseTime, error } = await timeRequest(() => container.inspect()); - + const docker = new this.DockerLib(this.toDockerOptions(monitor)); + // Host reachability is the monitor's status; ping latency is the responseTime + const { responseTime, error } = await timeRequest(() => docker.ping()); if (error) { - let message = "Failed to fetch Docker container information"; + let message = "Docker host is unreachable"; let code = NETWORK_ERROR; - if (this.isDockerError(error)) { code = error.statusCode ?? NETWORK_ERROR; message = error.json?.message ?? error.reason ?? error.message; } else if (error instanceof Error) { message = error.message; } - return { monitorId: monitor.id, teamId: monitor.teamId, type: monitor.type, status: false, - code: code, - message: message, - responseTime, - payload: null, - }; - } - - if (!response) { - return { - monitorId: monitor.id, - teamId: monitor.teamId, - type: monitor.type, - status: false, - code: NETWORK_ERROR, - message: "No response from Docker container inspect", + code, + message, responseTime, payload: null, }; } + const summaries = await docker.listContainers({ all: true }); + const containers = await this.mapWithConcurrency(summaries, STATS_CONCURRENCY, (s) => this.toContainerInfo(docker, s)); + const summary: DockerContainerSummary = { + total: containers.length, + running: containers.filter((c) => c.state === "running").length, + stopped: containers.filter((c) => c.state === "exited" || c.state === "dead").length, + unhealthy: containers.filter((c) => c.health === "unhealthy").length, + }; return { monitorId: monitor.id, teamId: monitor.teamId, type: monitor.type, - status: response.State?.Status === "running", + status: true, code: 200, - message: "Docker container status fetched successfully", + message: "Docker host is reachable", responseTime, - payload: response as unknown as DockerStatusPayload, + payload: { containers, summary }, }; - } catch (err: unknown) { + } catch (error: unknown) { + if (error instanceof AppError) throw error; throw new AppError({ - message: err instanceof Error ? err.message : "Error performing Docker request", + message: error instanceof Error ? error.message : "Error performing Docker request", service: SERVICE_NAME, method: "handle", - details: { url: containerInput }, + details: { + url: monitor.url, + }, }); } - } + }; } diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 0c8dd7cae..f92268f42 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -96,7 +96,39 @@ export interface HardwareStatusPayload { [key: string]: unknown; } -export type DockerStatusPayload = Record; +// Docker host monitoring +export const DockerContainerStates = ["created", "running", "paused", "restarting", "removing", "exited", "dead"]; +export type DockerContainerState = (typeof DockerContainerStates)[number]; + +export const DockerHealthStatuses = ["healthy", "unhealthy", "starting", "none"] as const; +export type DockerHealthStatus = (typeof DockerHealthStatuses)[number]; + +export interface DockerContainerSummary { + total: number; + running: number; + stopped: number; + unhealthy: number; +} + +export interface DockerContainerInfo { + id: string; + name: string; + image: string; + state: DockerContainerState; + status: string; + health: DockerHealthStatus; + cpuPct?: number; + memoryUsedBytes?: number; + memoryLimitBytes?: number; + memoryPct?: number; + restartCount?: number; + startedAt?: string; // ISO date +} + +export interface DockerStatusPayload { + containers: DockerContainerInfo[]; + summary: DockerContainerSummary; +} export interface PortStatusPayload { success: boolean; From 8f6c909acdb0abff08ff0eeac9d893a5212b574f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 12:28:26 -0700 Subject: [PATCH 02/13] add missing const, add key check --- server/src/service/network/DockerProvider.ts | 15 ++++++++++++--- server/src/types/network.ts | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index 543f73011..a24ad2c18 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -47,19 +47,28 @@ export class DockerProvider implements IStatusProvider { if (!url) throw new AppError({ message: "Docker host URL is required", status: 422, service: SERVICE_NAME, method: "toDockerOptions" }); if (url.startsWith("unix://")) { const socketPath = url.slice("unix://".length); - if (!socketPath.startsWith("/")) throw new Error(`Invalid Docker host URL: ${url}`); + if (!socketPath.startsWith("/")) + throw new AppError({ message: `Invalid Docker host URL: ${url}`, status: 422, service: SERVICE_NAME, method: "toDockerOptions" }); return { socketPath }; } if (url.startsWith("/")) return { socketPath: url }; const match = url.match(/^ssh:\/\/(?:([^@\s]+)@)?([^\s/:@]+)(?::(\d{1,5}))?\/?$/); - if (!match) throw new AppError({ message: "Invalid Docker host URL", status: 422, service: SERVICE_NAME, method: "toDockerOptions" }); + if (!match) throw new AppError({ message: `Invalid Docker host URL: ${url}`, status: 422, service: SERVICE_NAME, method: "toDockerOptions" }); const [, username, host, port] = match; if (!username) throw new AppError({ message: "SSH Docker host URL requires a user: ssh://user@host", status: 422, service: SERVICE_NAME, - method: "ToDockerOptions", + method: "toDockerOptions", + }); + + if (!monitor.sshPrivateKey) + throw new AppError({ + message: "SSH Docker host requires a private key", + status: 422, + service: SERVICE_NAME, + method: "toDockerOptions", }); return { protocol: "ssh", diff --git a/server/src/types/network.ts b/server/src/types/network.ts index f92268f42..62f359b46 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -97,7 +97,7 @@ export interface HardwareStatusPayload { } // Docker host monitoring -export const DockerContainerStates = ["created", "running", "paused", "restarting", "removing", "exited", "dead"]; +export const DockerContainerStates = ["created", "running", "paused", "restarting", "removing", "exited", "dead"] as const; export type DockerContainerState = (typeof DockerContainerStates)[number]; export const DockerHealthStatuses = ["healthy", "unhealthy", "starting", "none"] as const; From 9c8bf4bb0583d52a05cd0617b990cd9a958f878b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 12:39:56 -0700 Subject: [PATCH 03/13] update tests --- .../providers/network/dockerProvider.test.ts | 556 ++++++++++-------- 1 file changed, 303 insertions(+), 253 deletions(-) diff --git a/server/test/unit/providers/network/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts index 6d16b3e5c..4aa1431dc 100644 --- a/server/test/unit/providers/network/dockerProvider.test.ts +++ b/server/test/unit/providers/network/dockerProvider.test.ts @@ -3,51 +3,75 @@ import { DockerProvider } from "../../../../src/service/network/DockerProvider.t import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; import { createMockLogger } from "../../../helpers/createMockLogger.ts"; import { NETWORK_ERROR } from "../../../../src/types/network.ts"; +import { AppError } from "../../../../src/utils/AppError.ts"; import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts"; -// ── Helpers ────────────────────────────────────────────────────────────────── +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const PRIVATE_KEY = "-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n-----END OPENSSH PRIVATE KEY-----"; const makeMonitor = (overrides?: Partial): Monitor => ({ id: "mon-1", teamId: "team-1", type: "docker", - url: "my-container", + url: "unix:///var/run/docker.sock", ...overrides, }) as Monitor; const makeContainer = (overrides?: Record) => ({ Id: "abc123def456abc123def456abc123def456abc123def456abc123def456abcd", Names: ["/my-container"], + Image: "nginx:latest", State: "running", + Status: "Up 3 hours (healthy)", ...overrides, }); -const createMockDocker = (containers: any[] = [makeContainer()], inspectResult?: any) => { - const inspect = jest.fn().mockResolvedValue(inspectResult ?? { State: { Status: "running" } }); - const getContainer = jest.fn().mockReturnValue({ inspect }); +const makeInspect = (overrides?: Record) => ({ + RestartCount: 2, + State: { StartedAt: "2026-08-28T10:00:00.000Z", Health: { Status: "healthy" } }, + ...overrides, +}); +// cpuDelta 200M over systemDelta 1000M on 4 cpus → 80%; memory 300MiB raw - 100MiB page cache = 200MiB of 1GiB → 19.53125% +const makeStats = (overrides?: Record) => ({ + cpu_stats: { + cpu_usage: { total_usage: 400_000_000 }, + system_cpu_usage: 2_000_000_000, + online_cpus: 4, + }, + precpu_stats: { + cpu_usage: { total_usage: 200_000_000 }, + system_cpu_usage: 1_000_000_000, + }, + memory_stats: { + usage: 300 * 1024 * 1024, + limit: 1024 * 1024 * 1024, + stats: { inactive_file: 100 * 1024 * 1024 }, + }, + ...overrides, +}); + +const setup = (overrides: Partial<{ ping: any; listContainers: any; getContainer: any; inspect: any; stats: any }> = {}) => { + const inspect = overrides.inspect ?? jest.fn().mockResolvedValue(makeInspect()); + const stats = overrides.stats ?? jest.fn().mockResolvedValue(makeStats()); + const getContainer = overrides.getContainer ?? jest.fn().mockReturnValue({ inspect, stats }); const instance = { - listContainers: jest.fn().mockResolvedValue(containers), + ping: overrides.ping ?? jest.fn().mockResolvedValue("OK"), + listContainers: overrides.listContainers ?? jest.fn().mockResolvedValue([makeContainer()]), getContainer, }; - const DockerLib = jest.fn().mockReturnValue(instance) as any; - - return { DockerLib, instance, inspect, getContainer }; -}; - -const createProvider = (opts?: { containers?: any[]; inspectResult?: any }) => { const logger = createMockLogger(); - const { DockerLib, instance, inspect, getContainer } = createMockDocker(opts?.containers, opts?.inspectResult); const provider = new DockerProvider(logger as any, DockerLib); - return { provider, logger, docker: instance, inspect, getContainer }; + return { provider, logger, DockerLib, ping: instance.ping, listContainers: instance.listContainers, getContainer, inspect, stats }; }; // ── Contract ───────────────────────────────────────────────────────────────── testStatusProviderContract("DockerProvider", { - create: () => createProvider().provider, + create: () => setup().provider, supportedType: "docker", unsupportedType: "http", makeMonitor: () => makeMonitor(), @@ -56,286 +80,312 @@ testStatusProviderContract("DockerProvider", { // ── Tests ──────────────────────────────────────────────────────────────────── describe("DockerProvider", () => { - // ── Container matching ─────────────────────────────────────────────── + // ── Host url parsing ───────────────────────────────────────────────── - describe("container matching", () => { - it("matches by exact container name", async () => { - const { provider } = createProvider(); + describe("host url parsing", () => { + it("parses unix:// urls into socketPath", async () => { + const { provider, DockerLib } = setup(); - const result = await provider.handle(makeMonitor({ url: "my-container" })); + await provider.handle(makeMonitor({ url: "unix:///run/docker.sock" })); + + expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/run/docker.sock" }); + }); + + it("parses bare absolute paths into socketPath", async () => { + const { provider, DockerLib } = setup(); + + await provider.handle(makeMonitor({ url: "/var/run/docker.sock" })); + + expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/var/run/docker.sock" }); + }); + + it("trims surrounding whitespace", async () => { + const { provider, DockerLib } = setup(); + + await provider.handle(makeMonitor({ url: " /var/run/docker.sock " })); + + expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/var/run/docker.sock" }); + }); + + it("parses ssh urls with a private key, defaulting to port 22", async () => { + const { provider, DockerLib } = setup(); + + await provider.handle(makeMonitor({ url: "ssh://deploy@prod-swarm-01.internal", sshPrivateKey: PRIVATE_KEY })); + + expect(DockerLib).toHaveBeenCalledWith({ + protocol: "ssh", + host: "prod-swarm-01.internal", + port: 22, + username: "deploy", + sshOptions: { privateKey: PRIVATE_KEY }, + }); + }); + + it("parses an explicit ssh port", async () => { + const { provider, DockerLib } = setup(); + + await provider.handle(makeMonitor({ url: "ssh://deploy@host:2222", sshPrivateKey: PRIVATE_KEY })); + + expect(DockerLib).toHaveBeenCalledWith(expect.objectContaining({ port: 2222 })); + }); + }); + + // ── Fail-loudly url validation ─────────────────────────────────────── + + describe("invalid host urls", () => { + const invalidUrls: [label: string, url: string, message: string][] = [ + ["empty url", "", "Docker host URL is required"], + ["unix:// with an empty path", "unix://", "Invalid Docker host URL"], + ["unix:// with a relative path", "unix://run/docker.sock", "Invalid Docker host URL"], + ["garbage", "not-a-url", "Invalid Docker host URL"], + ["a container name (old semantics)", "my-container", "Invalid Docker host URL"], + ["tcp engine urls (unsupported)", "tcp://host:2375", "Invalid Docker host URL"], + ["https engine urls (unsupported)", "https://host", "Invalid Docker host URL"], + ["ssh without a user", "ssh://host", "SSH Docker host URL requires a user: ssh://user@host"], + ]; + + it.each(invalidUrls)("throws AppError for %s and never constructs a client", async (_label, url, message) => { + const { provider, DockerLib } = setup(); + + await expect(provider.handle(makeMonitor({ url }))).rejects.toThrow(message); + expect(DockerLib).not.toHaveBeenCalled(); + }); + + it("throws AppError for an ssh url with no private key", async () => { + const { provider, DockerLib } = setup(); + + await expect(provider.handle(makeMonitor({ url: "ssh://deploy@host", sshPrivateKey: undefined }))).rejects.toThrow( + "SSH Docker host requires a private key" + ); + expect(DockerLib).not.toHaveBeenCalled(); + }); + + it("preserves the toDockerOptions AppError through handle's catch", async () => { + const { provider } = setup(); + + await expect(provider.handle(makeMonitor({ url: "not-a-url" }))).rejects.toMatchObject({ + service: "DockerProvider", + method: "toDockerOptions", + status: 422, + }); + await expect(provider.handle(makeMonitor({ url: "not-a-url" }))).rejects.toBeInstanceOf(AppError); + }); + }); + + // ── Host reachability ──────────────────────────────────────────────── + + describe("host reachability", () => { + it("returns a down check with NETWORK_ERROR when ping rejects with a plain Error", async () => { + const { provider, listContainers } = setup({ ping: jest.fn().mockRejectedValue(new Error("ECONNREFUSED")) }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.code).toBe(NETWORK_ERROR); + expect(result.message).toBe("ECONNREFUSED"); + expect(result.payload).toBeNull(); + expect(listContainers).not.toHaveBeenCalled(); + }); + + it("uses the DockerError statusCode and json message when ping rejects with one", async () => { + const dockerErr = Object.assign(new Error("base"), { statusCode: 500, json: { message: "engine exploded" } }); + const { provider } = setup({ ping: jest.fn().mockRejectedValue(dockerErr) }); + + const result = await provider.handle(makeMonitor()); + + expect(result.code).toBe(500); + expect(result.message).toBe("engine exploded"); + }); + + it("falls back to the DockerError reason when json has no message", async () => { + const dockerErr = Object.assign(new Error("base"), { statusCode: 500, reason: "server error", json: {} }); + const { provider } = setup({ ping: jest.fn().mockRejectedValue(dockerErr) }); + + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("server error"); + }); + + it("uses the default message when ping rejects with a non-Error", async () => { + const { provider } = setup({ ping: jest.fn().mockRejectedValue("nope") }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.message).toBe("Docker host is unreachable"); + }); + }); + + // ── Happy path ─────────────────────────────────────────────────────── + + describe("container payload", () => { + it("returns an up check with enriched containers and summary counts", async () => { + const { provider } = setup(); + + const result = await provider.handle(makeMonitor()); expect(result.status).toBe(true); expect(result.code).toBe(200); - }); - - it("matches by exact full ID (64 chars)", async () => { - const fullId = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd"; - const { provider } = createProvider(); - - const result = await provider.handle(makeMonitor({ url: fullId })); - - expect(result.status).toBe(true); - }); - - it("matches by partial ID prefix", async () => { - const { provider } = createProvider(); - - const result = await provider.handle(makeMonitor({ url: "abc123" })); - - expect(result.status).toBe(true); - }); - - it("strips leading slashes from input", async () => { - const { provider } = createProvider(); - - const result = await provider.handle(makeMonitor({ url: "///my-container" })); - - expect(result.status).toBe(true); - }); - - it("matches case-insensitively", async () => { - const { provider } = createProvider(); - - const result = await provider.handle(makeMonitor({ url: "MY-CONTAINER" })); - - expect(result.status).toBe(true); - }); - - it("returns 404 when no container matches", async () => { - const { provider, logger } = createProvider(); - - const result = await provider.handle(makeMonitor({ url: "nonexistent" })); - - expect(result.status).toBe(false); - expect(result.code).toBe(404); - expect(result.message).toBe("Docker container not found"); - expect(logger.warn).toHaveBeenCalledWith( + expect(result.message).toBe("Docker host is reachable"); + expect(result.payload?.summary).toEqual({ total: 1, running: 1, stopped: 0, unhealthy: 0 }); + expect(result.payload?.containers[0]).toEqual( expect.objectContaining({ - message: expect.stringContaining("No container found"), - }) - ); - }); - }); - - // ── Ambiguity detection ────────────────────────────────────────────── - - describe("ambiguity detection", () => { - it("returns error when input matches both exact name and partial ID", async () => { - // Container whose name matches the input AND whose ID starts with the input - const container = makeContainer({ - Id: "my-containerabc123def456abc123def456abc123def456abc123def456abcd", - Names: ["/my-container"], - }); - const { provider, logger } = createProvider({ containers: [container] }); - - const result = await provider.handle(makeMonitor({ url: "my-container" })); - - expect(result.status).toBe(false); - expect(result.code).toBe(NETWORK_ERROR); - expect(result.message).toContain("Ambiguous"); - expect(logger.warn).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining("Ambiguous"), + id: makeContainer().Id, + name: "my-container", + image: "nginx:latest", + state: "running", + status: "Up 3 hours (healthy)", + health: "healthy", + restartCount: 2, + startedAt: "2026-08-28T10:00:00.000Z", }) ); }); - it("reports 'exact ID' in ambiguity message when exact ID and name both match", async () => { - const fullId = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd"; - const container = makeContainer({ - Id: fullId, - Names: ["/" + fullId], - }); - const { provider } = createProvider({ containers: [container] }); + it("counts running, stopped, and unhealthy states in the summary", async () => { + const containers = [ + makeContainer({ Id: "a".repeat(64) }), + makeContainer({ Id: "b".repeat(64), State: "exited" }), + makeContainer({ Id: "c".repeat(64), State: "dead" }), + makeContainer({ Id: "d".repeat(64), State: "paused" }), + ]; + const inspect = jest.fn().mockResolvedValue(makeInspect({ State: { Health: { Status: "unhealthy" } } })); + const { provider } = setup({ listContainers: jest.fn().mockResolvedValue(containers), inspect }); - const result = await provider.handle(makeMonitor({ url: fullId })); + const result = await provider.handle(makeMonitor()); - expect(result.message).toContain("Using exact ID"); + expect(result.payload?.summary).toEqual({ total: 4, running: 1, stopped: 2, unhealthy: 4 }); }); - it("reports 'exact name' in ambiguity message when name and partial ID match", async () => { - const container = makeContainer({ - Id: "my-containerabc123def456abc123def456abc123def456abc123def456abcd", - Names: ["/my-container"], - }); - const { provider } = createProvider({ containers: [container] }); + it("falls back to a truncated id when a container has no name", async () => { + const container = makeContainer({ Names: [] }); + const { provider } = setup({ listContainers: jest.fn().mockResolvedValue([container]) }); - const result = await provider.handle(makeMonitor({ url: "my-container" })); + const result = await provider.handle(makeMonitor()); - expect(result.message).toContain("Using exact name"); + expect(result.payload?.containers[0]?.name).toBe(container.Id.slice(0, 12)); + }); + + it("clamps unknown container states and health statuses", async () => { + const container = makeContainer({ State: "warp-speed" }); + const inspect = jest.fn().mockResolvedValue(makeInspect({ State: { Health: { Status: "confused" } } })); + const { provider } = setup({ listContainers: jest.fn().mockResolvedValue([container]), inspect }); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload?.containers[0]?.state).toBe("created"); + expect(result.payload?.containers[0]?.health).toBe("none"); }); }); - // ── Container inspection ───────────────────────────────────────────── + // ── Enrichment fault isolation ─────────────────────────────────────── - describe("container inspection", () => { - it("returns running status for running container", async () => { - const { provider } = createProvider({ inspectResult: { State: { Status: "running" } } }); + describe("enrichment fault isolation", () => { + it("keeps the container and the up status when inspect rejects", async () => { + const inspect = jest.fn().mockRejectedValue(new Error("inspect failed")); + const { provider, logger } = setup({ inspect }); const result = await provider.handle(makeMonitor()); expect(result.status).toBe(true); - expect(result.message).toBe("Docker container status fetched successfully"); + expect(result.payload?.containers).toHaveLength(1); + expect(result.payload?.containers[0]).toEqual(expect.objectContaining({ health: "none" })); + expect(result.payload?.containers[0]?.restartCount).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Failed to inspect") })); }); - it("returns not-running status for stopped container", async () => { - const { provider } = createProvider({ inspectResult: { State: { Status: "exited" } } }); + it("keeps the container without metrics when stats rejects", async () => { + const stats = jest.fn().mockRejectedValue(new Error("stats failed")); + const { provider } = setup({ stats }); const result = await provider.handle(makeMonitor()); - expect(result.status).toBe(false); + expect(result.status).toBe(true); + expect(result.payload?.containers[0]?.cpuPct).toBeUndefined(); + expect(result.payload?.containers[0]?.memoryUsedBytes).toBeUndefined(); }); - it("returns NETWORK_ERROR when inspect response is null", async () => { - const logger = createMockLogger(); - const inspect = jest.fn().mockResolvedValue(null); - const getContainer = jest.fn().mockReturnValue({ inspect }); - const instance = { listContainers: jest.fn().mockResolvedValue([makeContainer()]), getContainer }; - const DockerLib = jest.fn().mockReturnValue(instance) as any; - const provider = new DockerProvider(logger as any, DockerLib); + it("only requests stats for running containers", async () => { + const containers = [makeContainer({ Id: "a".repeat(64) }), makeContainer({ Id: "b".repeat(64), State: "exited" })]; + const { provider, stats } = setup({ listContainers: jest.fn().mockResolvedValue(containers) }); - const result = await provider.handle(makeMonitor()); + await provider.handle(makeMonitor()); - expect(result.status).toBe(false); - expect(result.code).toBe(NETWORK_ERROR); - expect(result.message).toBe("No response from Docker container inspect"); - }); - - it("returns false status when inspect response has no State", async () => { - const { provider } = createProvider({ inspectResult: {} }); - - const result = await provider.handle(makeMonitor()); - - expect(result.status).toBe(false); - }); - - it("returns false status when inspect response has State but no Status", async () => { - const { provider } = createProvider({ inspectResult: { State: {} } }); - - const result = await provider.handle(makeMonitor()); - - expect(result.status).toBe(false); - }); - - it("handles inspect error with Docker-specific error", async () => { - const { provider } = createProvider(); - // Make inspect throw a Docker error - const dockerErr = Object.assign(new Error("container not found"), { - statusCode: 404, - reason: "no such container", - json: { message: "Container not found" }, - }); - provider["docker"] = { - listContainers: jest.fn().mockResolvedValue([makeContainer()]), - getContainer: jest.fn().mockReturnValue({ - inspect: jest.fn().mockRejectedValue(dockerErr), - }), - } as any; - - // Need to re-assign; simpler to use the createProvider mock - const { provider: p2 } = createProvider(); - // Override getContainer to return failing inspect - p2["docker"].getContainer = jest.fn().mockReturnValue({ - inspect: jest.fn().mockRejectedValue(dockerErr), - }); - - const result = await p2.handle(makeMonitor()); - - expect(result.status).toBe(false); - expect(result.code).toBe(404); - expect(result.message).toBe("Container not found"); - }); - - it("handles inspect error with Docker error using reason fallback", async () => { - const { provider } = createProvider(); - const dockerErr = Object.assign(new Error("error"), { - statusCode: 500, - reason: "internal error", - json: {}, - }); - provider["docker"].getContainer = jest.fn().mockReturnValue({ - inspect: jest.fn().mockRejectedValue(dockerErr), - }); - - const result = await provider.handle(makeMonitor()); - - expect(result.message).toBe("internal error"); - }); - - it("handles inspect error with Docker error using Error message fallback", async () => { - const { provider } = createProvider(); - const dockerErr = Object.assign(new Error("base error"), { - statusCode: 503, - reason: undefined, - json: undefined, - }); - provider["docker"].getContainer = jest.fn().mockReturnValue({ - inspect: jest.fn().mockRejectedValue(dockerErr), - }); - - const result = await provider.handle(makeMonitor()); - - expect(result.message).toBe("base error"); - }); - - it("handles inspect error with NETWORK_ERROR when no statusCode", async () => { - const { provider } = createProvider(); - const dockerErr = Object.assign(new Error("unknown"), { - statusCode: undefined, - }); - provider["docker"].getContainer = jest.fn().mockReturnValue({ - inspect: jest.fn().mockRejectedValue(dockerErr), - }); - - const result = await provider.handle(makeMonitor()); - - expect(result.code).toBe(NETWORK_ERROR); - }); - - it("handles inspect error with standard Error (not Docker error)", async () => { - const { provider } = createProvider(); - provider["docker"].getContainer = jest.fn().mockReturnValue({ - inspect: jest.fn().mockRejectedValue(new Error("ECONNREFUSED")), - }); - - const result = await provider.handle(makeMonitor()); - - expect(result.status).toBe(false); - expect(result.message).toBe("ECONNREFUSED"); - }); - - it("handles inspect error with non-Error non-Docker thrown value", async () => { - const { provider } = createProvider(); - provider["docker"].getContainer = jest.fn().mockReturnValue({ - inspect: jest.fn().mockRejectedValue("string error"), - }); - - const result = await provider.handle(makeMonitor()); - - expect(result.status).toBe(false); - expect(result.message).toBe("Failed to fetch Docker container information"); + expect(stats).toHaveBeenCalledTimes(1); }); }); - // ── Outer catch ────────────────────────────────────────────────────── + // ── Metric math ────────────────────────────────────────────────────── + + describe("metric math", () => { + it("computes cpu and memory percentages from cgroup v2 stats", async () => { + const { provider } = setup(); + + const result = await provider.handle(makeMonitor()); + const container = result.payload?.containers[0]; + + expect(container?.cpuPct).toBeCloseTo(80); + expect(container?.memoryUsedBytes).toBe(200 * 1024 * 1024); + expect(container?.memoryLimitBytes).toBe(1024 * 1024 * 1024); + expect(container?.memoryPct).toBeCloseTo(19.53125); + }); + + it("falls back to the cgroup v1 cache field for page cache", async () => { + const stats = jest + .fn() + .mockResolvedValue(makeStats({ memory_stats: { usage: 300 * 1024 * 1024, limit: 1024 * 1024 * 1024, stats: { cache: 50 * 1024 * 1024 } } })); + const { provider } = setup({ stats }); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload?.containers[0]?.memoryUsedBytes).toBe(250 * 1024 * 1024); + }); + + it("derives cpu count from percpu_usage when online_cpus is absent", async () => { + const stats = jest.fn().mockResolvedValue( + makeStats({ + cpu_stats: { cpu_usage: { total_usage: 400_000_000, percpu_usage: [1, 2] }, system_cpu_usage: 2_000_000_000 }, + }) + ); + const { provider } = setup({ stats }); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload?.containers[0]?.cpuPct).toBeCloseTo(40); + }); + + it("returns 0 cpu when the system delta is not positive", async () => { + const stats = jest + .fn() + .mockResolvedValue(makeStats({ precpu_stats: { cpu_usage: { total_usage: 200_000_000 }, system_cpu_usage: 2_000_000_000 } })); + const { provider } = setup({ stats }); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload?.containers[0]?.cpuPct).toBe(0); + }); + + it("returns 0 memory percent when there is no limit", async () => { + const stats = jest.fn().mockResolvedValue(makeStats({ memory_stats: { usage: 300 * 1024 * 1024, limit: 0, stats: {} } })); + const { provider } = setup({ stats }); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload?.containers[0]?.memoryPct).toBe(0); + }); + }); + + // ── Outer error handling ───────────────────────────────────────────── describe("outer error handling", () => { - it("throws AppError when containerInput is missing", async () => { - const { provider } = createProvider(); - - await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("Container name or ID is required for Docker monitor"); - }); - - it("throws AppError when listContainers throws", async () => { - const { provider } = createProvider(); - provider["docker"].listContainers = jest.fn().mockRejectedValue(new Error("Docker daemon unavailable")); + it("throws AppError when listContainers rejects after a successful ping", async () => { + const { provider } = setup({ listContainers: jest.fn().mockRejectedValue(new Error("Docker daemon unavailable")) }); await expect(provider.handle(makeMonitor())).rejects.toThrow("Docker daemon unavailable"); }); - it("throws AppError with default message for non-Error thrown values", async () => { - const { provider } = createProvider(); - provider["docker"].listContainers = jest.fn().mockRejectedValue(null); + it("throws AppError with the default message for non-Error thrown values", async () => { + const { provider } = setup({ listContainers: jest.fn().mockRejectedValue(null) }); await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing Docker request"); }); From 686ded2137154b07c932de980557892ee11c5e57 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 13:02:38 -0700 Subject: [PATCH 04/13] update check and monitor domains --- .../api/validation/statusPageValidation.ts | 1 + server/src/domain/checks/check.model.ts | 39 ++++++++++++++++++- .../domain/checks/check.repository.mongo.ts | 2 + server/src/domain/checks/check.service.ts | 8 +++- server/src/domain/checks/check.snapshot.ts | 6 +++ server/src/domain/checks/check.type.ts | 31 +++++++++++++++ server/src/domain/monitors/monitor.model.ts | 2 + 7 files changed, 87 insertions(+), 2 deletions(-) diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index ad2277797..e92f28a57 100644 --- a/server/src/api/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -112,6 +112,7 @@ export const checkSnapshotResponseSchema = z bestPractices: z.number().optional(), seo: z.number().optional(), performance: z.number().optional(), + containerSummary: z.object({ total: z.number(), running: z.number(), stopped: z.number(), unhealthy: z.number() }).optional(), }) .passthrough(); diff --git a/server/src/domain/checks/check.model.ts b/server/src/domain/checks/check.model.ts index 9f3c2fbc1..a101375f4 100644 --- a/server/src/domain/checks/check.model.ts +++ b/server/src/domain/checks/check.model.ts @@ -14,6 +14,7 @@ import type { GotTimings, ILighthouseAudit, } from "@/domain/checks/check.type.js"; +import { DockerContainerInfo, DockerContainerStates, DockerContainerSummary, DockerHealthStatuses } from "@/types/network.js"; type CheckMetadataDocument = Omit & { monitorId: Types.ObjectId; @@ -172,6 +173,34 @@ const auditsSchema = new Schema( { _id: false } ); +const dockerContainerSchema = new Schema( + { + id: { type: String, required: true }, + name: { type: String, default: "" }, + image: { type: String, default: "" }, + state: { type: String, enum: DockerContainerStates, default: "created" }, + status: { type: String, default: "" }, + health: { type: String, enum: DockerHealthStatuses, default: "none" }, + cpuPct: { type: Number }, + memoryUsedBytes: { type: Number }, + memoryLimitBytes: { type: Number }, + memoryPct: { type: Number }, + restartCount: { type: Number }, + startedAt: { type: String }, + }, + { _id: false } +); + +const containerSummarySchema = new Schema( + { + total: { type: Number, default: 0 }, + running: { type: Number, default: 0 }, + stopped: { type: Number, default: 0 }, + unhealthy: { type: Number, default: 0 }, + }, + { _id: false } +); + const metadataSchema = new Schema( { monitorId: { @@ -268,6 +297,14 @@ const CheckSchema = new Schema( type: auditsSchema, default: undefined, }, + containers: { + type: [dockerContainerSchema], + default: undefined, + }, + containerSummary: { + type: containerSummarySchema, + default: undefined, + }, }, { timestamps: true, @@ -291,5 +328,5 @@ CheckSchema.index({ "metadata.teamId": 1, status: 1, createdAt: -1 }); const CheckModel = model("Check", CheckSchema); export type { CheckDocument, CheckMetadataDocument }; -export { CheckModel }; +export { CheckModel, containerSummarySchema }; export default CheckModel; diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index e64113b6c..9b46f2dbd 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -167,6 +167,8 @@ class MongoChecksRepository implements IChecksRepository { seo: doc.seo, performance: doc.performance, audits: mapAudits(doc.audits), + containers: doc.containers, + containerSummary: doc.containerSummary, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; diff --git a/server/src/domain/checks/check.service.ts b/server/src/domain/checks/check.service.ts index 40dd88f80..5eb071bab 100644 --- a/server/src/domain/checks/check.service.ts +++ b/server/src/domain/checks/check.service.ts @@ -2,7 +2,7 @@ import { Types } from "mongoose"; import { IChecksRepository } from "@/domain/checks/check.repository.interface.js"; import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; import type { Check, CheckErrorInfo, ChecksQueryResult, ChecksSummary, ILighthouseAudit } from "@/domain/checks/check.type.js"; -import type { MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js"; +import type { DockerStatusPayload, MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js"; import type { HardwareStatusPayload, PageSpeedStatusPayload } from "@/types/network.js"; import { AppError } from "@/utils/AppError.js"; import { ILogger } from "@/utils/logger.js"; @@ -131,6 +131,12 @@ export class CheckService implements ICheckService { check.capture = hardwarePayload?.capture; check.net = net; } + + if (type === "docker") { + const dockerPayload = payload as DockerStatusPayload | undefined; + check.containers = dockerPayload?.containers; + check.containerSummary = dockerPayload?.summary; + } return check; }; diff --git a/server/src/domain/checks/check.snapshot.ts b/server/src/domain/checks/check.snapshot.ts index 3ee208d04..7348a2d55 100644 --- a/server/src/domain/checks/check.snapshot.ts +++ b/server/src/domain/checks/check.snapshot.ts @@ -11,6 +11,7 @@ import type { SnapshotHostInfo, SnapshotMemoryInfo, } from "@/domain/checks/check.type.js"; +import { DockerContainerSummary } from "@/types/network.js"; import { toDateString } from "@/utils/mongoMappers.js"; export type CheckSnapshotSource = Pick< @@ -23,6 +24,7 @@ export type CheckSnapshotSource = Pick< disk?: CheckDiskInfo[]; host?: CheckHostInfo; audits?: CheckAudits; + containerSummary?: DockerContainerSummary; }; const mapCpu = (cpu?: CheckCpuInfo): SnapshotCpuInfo | undefined => @@ -66,6 +68,9 @@ const mapAudits = (audits?: CheckAudits): CheckAudits | undefined => tbt: audits.tbt, }; +const mapContainerSummary = (summary?: DockerContainerSummary): DockerContainerSummary | undefined => + summary && { total: summary.total, running: summary.running, stopped: summary.stopped, unhealthy: summary.unhealthy }; + export const toCheckSnapshot = (source: CheckSnapshotSource): CheckSnapshot => ({ id: source.id, status: source.status, @@ -82,4 +87,5 @@ export const toCheckSnapshot = (source: CheckSnapshotSource): CheckSnapshot => ( seo: source.seo, performance: source.performance, audits: mapAudits(source.audits), + containerSummary: mapContainerSummary(source.containerSummary), }); diff --git a/server/src/domain/checks/check.type.ts b/server/src/domain/checks/check.type.ts index aa1eb3782..77486aa79 100644 --- a/server/src/domain/checks/check.type.ts +++ b/server/src/domain/checks/check.type.ts @@ -1,5 +1,7 @@ import type { MonitorType } from "@/domain/monitors/monitor.type.js"; +import { DockerContainerInfo, DockerContainerSummary } from "@/types/network.js"; import type { Response } from "got"; +import { number } from "zod"; export const CHECK_TTL_SENTINEL = 366; @@ -107,6 +109,8 @@ export interface Check { host?: CheckHostInfo; errors?: CheckErrorInfo[]; capture?: CheckCaptureInfo; + containers?: DockerContainerInfo[]; + containerSummary?: DockerContainerSummary; net?: CheckNetworkInterfaceInfo[]; accessibility?: number; bestPractices?: number; @@ -197,6 +201,8 @@ export type CheckSnapshot = Pick< memory?: SnapshotMemoryInfo; disk?: SnapshotDiskInfo[]; host?: SnapshotHostInfo; + // Docker Only + containerSummary?: DockerContainerSummary; }; export interface HardwareDiskStats { name: string; @@ -252,3 +258,28 @@ export type DailyCheckBucket = { downChecks: number; avgResponseTime: number | null; }; + +export type DockerStatsBucket = { + _id: string; + avgResponseTime: number | null; + upCount: number; + totalCount: number; + avgRunning: number | null; + avgTotal: number | null; + avgUnhealthy: number | null; +}; + +export interface DockerStats { + aggregateData: { totalChecks: number }; + upChecks: { totalChecks: number }; + aggregate: DockerStatsBucket[]; + latest: { + containers: DockerContainerInfo[]; + summary?: DockerContainerSummary; + checkedAt: string; + } | null; +} + +export interface DockerChecksResult extends DockerStats { + monitorType: "docker"; +} diff --git a/server/src/domain/monitors/monitor.model.ts b/server/src/domain/monitors/monitor.model.ts index ba803dc67..89888900a 100644 --- a/server/src/domain/monitors/monitor.model.ts +++ b/server/src/domain/monitors/monitor.model.ts @@ -9,6 +9,7 @@ import type { SnapshotHostInfo, SnapshotMemoryInfo, } from "@/domain/checks/check.type.js"; +import { containerSummarySchema } from "@/domain/checks/check.model.js"; type CheckSnapshotDocument = Omit & { createdAt: Date }; @@ -113,6 +114,7 @@ const checkSnapshotSchema = new Schema( seo: { type: Number }, performance: { type: Number }, audits: { type: snapshotAuditsSchema }, + containerSummary: { type: containerSummarySchema }, createdAt: { type: Date, required: true }, }, { _id: false, suppressReservedKeysWarning: true } From 03174d98dd0c9462afef05048742a2428bfafa50 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 13:30:21 -0700 Subject: [PATCH 05/13] update monitors --- server/src/domain/monitors/monitor.service.ts | 7 ++++--- server/src/domain/monitors/monitor.type.ts | 20 +++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index 6cf9af54b..33b8413db 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -9,7 +9,7 @@ import type { GroupedGeoCheckResult, } from "@/domain/monitors/monitor.type.js"; import { supportsGeoCheck, supportsUptimeDetails } from "@/domain/monitors/monitor.type.js"; -import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult } from "@/domain/checks/check.type.js"; +import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult, DockerChecksResult } from "@/domain/checks/check.type.js"; import type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js"; import type { IChecksRepository } from "@/domain/checks/check.repository.interface.js"; import type { IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.interface.js"; @@ -26,8 +26,9 @@ import { DateRange } from "@/types/query.js"; const SERVICE_NAME = "MonitorService"; -const isUptimeChecksResult = (result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult): result is UptimeChecksResult => - supportsUptimeDetails(result.monitorType); +const isUptimeChecksResult = ( + result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult | DockerChecksResult +): result is UptimeChecksResult => supportsUptimeDetails(result.monitorType); export interface IMonitorService { // create diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index 1767ba17f..e33f89c5c 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -1,9 +1,10 @@ -import type { CheckSnapshot } from "@/domain/checks/check.type.js"; +import type { CheckSnapshot, DockerStats } from "@/domain/checks/check.type.js"; export type { CheckSnapshot } from "@/domain/checks/check.type.js"; import type { GeoContinent, GroupedGeoCheck } from "@/domain/geo-checks/geo-check.type.js"; export type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js"; import http from "node:http"; import { HardwareStats } from "@/domain/checks/check.type.js"; +import { MonitorStats } from "@/domain/monitor-stats/monitor-stats.type.js"; export const HttpStatusCodes = [ ...Object.keys(http.STATUS_CODES).map(Number), @@ -44,16 +45,7 @@ export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupporte export const ProxyModes = ["inherit", "none", "custom"] as const; export type ProxyMode = (typeof ProxyModes)[number]; -export const UptimeDetailsSupportedTypes = [ - "http", - "ping", - "docker", - "port", - "game", - "grpc", - "websocket", - "dns", -] as const satisfies readonly MonitorType[]; +export const UptimeDetailsSupportedTypes = ["http", "ping", "port", "game", "grpc", "websocket", "dns"] as const satisfies readonly MonitorType[]; export type UptimeDetailsSupportedType = (typeof UptimeDetailsSupportedTypes)[number]; export const supportsUptimeDetails = (type: MonitorType): type is UptimeDetailsSupportedType => UptimeDetailsSupportedTypes.some((t) => t === type); @@ -162,6 +154,12 @@ export interface HardwareDetailsResult { monitorStats: import("../monitor-stats/monitor-stats.type.js").MonitorStats | null; } +export interface DockerDetailsResult { + monitor: Monitor; + stats: DockerStats; + monitorStats: MonitorStats | null; +} + export interface PageSpeedDetailsResult { monitorData: { monitor: Monitor; From 1a1dc7d807565630cebcc5049d4eea54159a4812 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 13:30:32 -0700 Subject: [PATCH 06/13] add docker check aggregation --- .../domain/checks/check.docker.aggregation.ts | 54 +++++++++++++++++++ .../checks/check.repository.interface.ts | 3 +- 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 server/src/domain/checks/check.docker.aggregation.ts diff --git a/server/src/domain/checks/check.docker.aggregation.ts b/server/src/domain/checks/check.docker.aggregation.ts new file mode 100644 index 000000000..9edd1dcbb --- /dev/null +++ b/server/src/domain/checks/check.docker.aggregation.ts @@ -0,0 +1,54 @@ +import CheckModel from "@/domain/checks/check.model.js"; +import { DockerStatsBucket } from "@/domain/checks/check.type.js"; +import mongoose from "mongoose"; + +type DateRange = { start: Date; end: Date }; + +export const getDockerTotalChecks = async (monitorId: string, dates: DateRange): Promise => + CheckModel.countDocuments({ + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + "metadata.type": "docker", + createdAt: { $gte: dates.start, $lte: dates.end }, + }); + +export const getDockerUpChecks = async (monitorId: string, dates: DateRange): Promise<{ totalChecks: number }> => { + const count = await CheckModel.countDocuments({ + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + "metadata.type": "docker", + createdAt: { $gte: dates.start, $lte: dates.end }, + status: true, + }); + return { totalChecks: count }; +}; + +export const getDockerStats = async (monitorId: string, dates: DateRange, dateString: string): Promise => + CheckModel.aggregate([ + { + $match: { + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + "metadata.type": "docker", + createdAt: { $gte: dates.start, $lte: dates.end }, + }, + }, + { + $group: { + _id: { $dateToString: { format: dateString, date: "$createdAt" } }, + avgResponseTime: { $avg: "$responseTime" }, + upCount: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } }, + totalCount: { $sum: 1 }, + avgRunning: { $avg: "$containerSummary.running" }, + avgTotal: { $avg: "$containerSummary.total" }, + avgUnhealthy: { $avg: "$containerSummary.unhealthy" }, + }, + }, + { $sort: { _id: 1 } }, + ]); + +export const getDockerLatestCheck = async (monitorId: string) => + CheckModel.findOne({ + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + "metadata.type": "docker", + }) + .sort({ createdAt: -1 }) + .select("containers containerSummary createdAt") + .lean(); diff --git a/server/src/domain/checks/check.repository.interface.ts b/server/src/domain/checks/check.repository.interface.ts index 1767b2ea1..298981216 100644 --- a/server/src/domain/checks/check.repository.interface.ts +++ b/server/src/domain/checks/check.repository.interface.ts @@ -3,6 +3,7 @@ import type { ChecksQueryResult, ChecksSummary, DailyCheckBucket, + DockerChecksResult, HardwareChecksResult, PageSpeedChecksResult, UptimeChecksResult, @@ -38,7 +39,7 @@ export interface IChecksRepository { monitorId: string, dateRange: DateRange, options?: { type?: MonitorType } - ): Promise; + ): Promise; findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise; findUnevaluatedByMonitorId(monitorId: string, since: number): Promise; getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string): Promise; From 78d3b23e930616d24175a882bdcc8e5bd942a4c7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 13:48:37 -0700 Subject: [PATCH 07/13] handle docker checks --- .../domain/checks/check.repository.mongo.ts | 30 +++++++++++++- server/src/domain/checks/check.type.ts | 4 +- server/src/domain/monitors/monitor.service.ts | 40 +++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index 9b46f2dbd..b779a2295 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -24,6 +24,7 @@ import { getHardwareUpChecks, getHardwareStats, getHardwareTotalChecks } from "@ import { CheckFilter, DateRange } from "@/types/query.js"; import { AppError } from "@/utils/AppError.js"; import { NETWORK_ERROR } from "@/types/network.js"; +import { getDockerLatestCheck, getDockerStats, getDockerTotalChecks, getDockerUpChecks } from "@/domain/checks/check.docker.aggregation.js"; const SERVICE_NAME = "ChecksRepository"; @@ -304,6 +305,9 @@ class MongoChecksRepository implements IChecksRepository { if (options?.type === "pagespeed") { return this.findPageSpeedDateRangeChecks(monitorObjectId, start, end, dateString); } + if (options?.type === "docker") { + return this.findDockerDateRangeChecks(monitorObjectId, start, end, dateString); + } return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorObjectId, start, end, dateString); }; @@ -414,7 +418,7 @@ class MongoChecksRepository implements IChecksRepository { return totalDeleted; }; private findUptimeDateRangeChecks = async ( - monitorType: Exclude, + monitorType: Exclude, monitorObjectId: mongoose.Types.ObjectId, startDate: Date, endDate: Date, @@ -630,6 +634,30 @@ class MongoChecksRepository implements IChecksRepository { groupedChecks: result?.groupedChecks ?? [], }; }; + + private findDockerDateRangeChecks = async (monitorObjectId: mongoose.Types.ObjectId, startDate: Date, endDate: Date, dateString: string) => { + const monitorId = monitorObjectId.toHexString(); + const dates = { start: startDate, end: endDate }; + const [totalChecks, upChecks, aggregate, latestDoc] = await Promise.all([ + getDockerTotalChecks(monitorId, dates), + getDockerUpChecks(monitorId, dates), + getDockerStats(monitorId, dates, dateString), + getDockerLatestCheck(monitorId), + ]); + return { + monitorType: "docker" as const, + aggregateData: { totalChecks: totalChecks ?? 0 }, + upChecks, + aggregate, + latest: latestDoc + ? { + containers: latestDoc.containers ?? [], + summary: latestDoc.containerSummary, + checkedAt: toDateString(latestDoc.createdAt), + } + : null, + }; + }; } export default MongoChecksRepository; diff --git a/server/src/domain/checks/check.type.ts b/server/src/domain/checks/check.type.ts index 77486aa79..00ab3641d 100644 --- a/server/src/domain/checks/check.type.ts +++ b/server/src/domain/checks/check.type.ts @@ -1,7 +1,6 @@ import type { MonitorType } from "@/domain/monitors/monitor.type.js"; import { DockerContainerInfo, DockerContainerSummary } from "@/types/network.js"; import type { Response } from "got"; -import { number } from "zod"; export const CHECK_TTL_SENTINEL = 366; @@ -151,11 +150,10 @@ export interface PageSpeedGroupedCheck { accessibility: number; bestPractices: number; seo: number; - totalChecks: number; } export interface UptimeChecksResult { - monitorType: Exclude; + monitorType: Exclude; groupedChecks: GroupedUptimeCheck[]; groupedUpChecks: GroupedCheck[]; groupedDownChecks: GroupedCheck[]; diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index 33b8413db..c0ff1e20f 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -7,6 +7,7 @@ import type { PageSpeedDetailsResult, GamesMap, GroupedGeoCheckResult, + DockerDetailsResult, } from "@/domain/monitors/monitor.type.js"; import { supportsGeoCheck, supportsUptimeDetails } from "@/domain/monitors/monitor.type.js"; import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult, DockerChecksResult } from "@/domain/checks/check.type.js"; @@ -40,6 +41,7 @@ export interface IMonitorService { getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; + getDockerDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; getGeoChecksByMonitorId(args: { teamId: string; monitorId: string; @@ -282,6 +284,44 @@ export class MonitorService implements IMonitorService { monitorStats, }; }; + getDockerDetailsById = async ({ + teamId, + monitorId, + dateRange, + }: { + teamId: string; + monitorId: string; + dateRange: DateRange; + }): Promise => { + const monitor = await this.monitorsRepository.findById(monitorId, teamId); + if (!monitor) { + throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 }); + } + if (monitor.type !== "docker") { + throw new AppError({ message: `${monitor.type} monitors are not supported for docker details`, status: 400 }); + } + + const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, dateRange, { + type: monitor.type, + }); + + if (checksData.monitorType !== "docker") { + throw new AppError({ message: "Unable to load docker stats for this monitor", status: 500 }); + } + + const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id); + + return { + monitor, + stats: { + aggregateData: checksData.aggregateData, + upChecks: checksData.upChecks, + aggregate: checksData.aggregate, + latest: checksData.latest, + }, + monitorStats, + }; + }; getGeoChecksByMonitorId = async ({ teamId, From 4ec03dfd046a5624f2a3ad3b7e92770c632e4c63 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 13:49:44 -0700 Subject: [PATCH 08/13] fix return type, copy paste bug --- server/src/domain/monitors/monitor.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index c0ff1e20f..14b488af2 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -41,7 +41,7 @@ export interface IMonitorService { getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; - getDockerDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; + getDockerDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; getGeoChecksByMonitorId(args: { teamId: string; monitorId: string; From cacedfd7d867f74d92a025253fc9d9d2f7699851 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 13:59:24 -0700 Subject: [PATCH 09/13] add validation --- .../src/api/validation/monitorValidation.ts | 92 ++++++++++++++++++- 1 file changed, 89 insertions(+), 3 deletions(-) diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index e694a216d..48477f731 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -13,6 +13,7 @@ import { ProxyModes, } from "@/domain/monitors/monitor.type.js"; import { DateRanges, SortOrders } from "@/types/query.js"; +import { DockerContainerStates, DockerHealthStatuses } from "@/types/network.js"; const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" }); @@ -118,6 +119,26 @@ const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ct } }; +const dockerUrlRegex = /^(?:ssh:\/\/[^@\s]+@[^\s/:@]+(?::\d{1,5})?\/?|unix:\/\/\/\S+|\/\S+)$/; + +const refineDockerUrl = (data: { type?: string; url?: string; sshPrivateKey?: string }, ctx: z.RefinementCtx) => { + if (data.type !== "docker" || data.url === undefined) return; + if (!dockerUrlRegex.test(data.url)) { + ctx.addIssue({ + code: "custom", + path: ["url"], + message: "Docker host must be ssh://user@host[:port], unix:///path, or an absolute socket path", + }); + } + if (data.url.startsWith("ssh://") && !data.sshPrivateKey) { + ctx.addIssue({ + code: "custom", + path: ["sshPrivateKey"], + message: "SSH Docker hosts require a private key", + }); + } +}; + export const createMonitorBodyValidation = z .object({ _id: z.string().optional(), @@ -142,6 +163,7 @@ export const createMonitorBodyValidation = z tags: z.array(z.string()).optional(), customUpCodes: z.array(httpStatusCode).default([]), secret: z.string().optional(), + sshPrivateKey: z.string().optional(), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), @@ -161,7 +183,8 @@ export const createMonitorBodyValidation = z .superRefine(refineStrategyType) .superRefine(refineHeadMatching) .superRefine(refineRegexPattern) - .superRefine(refineProxySelection); + .superRefine(refineProxySelection) + .superRefine(refineDockerUrl); export const editMonitorBodyValidation = z .object({ @@ -176,6 +199,7 @@ export const editMonitorBodyValidation = z tags: z.array(z.string()).optional(), customUpCodes: z.array(httpStatusCode).optional(), secret: z.string().optional(), + sshPrivateKey: z.string().optional(), ignoreTlsErrors: z.boolean().optional(), proxyMode: z.enum(ProxyModes).optional(), proxyId: proxyIdValidation, @@ -204,7 +228,8 @@ export const editMonitorBodyValidation = z .superRefine(refineStrategyType) .superRefine(refineHeadMatching) .superRefine(refineRegexPattern) - .superRefine(refineProxySelection); + .superRefine(refineProxySelection) + .superRefine(refineDockerUrl); export const pauseMonitorParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -255,6 +280,7 @@ const importedMonitorSchema = z tags: z.array(z.string()).default([]), customUpCodes: z.array(httpStatusCode).default([]), secret: z.string().optional(), + sshPrivateKey: z.string().optional(), cpuAlertThreshold: z.number().default(100), cpuAlertCounter: z.number().default(5), memoryAlertThreshold: z.number().default(100), @@ -280,7 +306,8 @@ const importedMonitorSchema = z .superRefine(refineStrategyType) .superRefine(refineHeadMatching) .superRefine(refineRegexPattern) - .superRefine(refineProxySelection); + .superRefine(refineProxySelection) + .superRefine(refineDockerUrl); export const importMonitorsBodyValidation = z.object({ monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"), @@ -296,6 +323,9 @@ export const getHardwareDetailsByIdQueryValidation = z.object({ dateRange: z.enum(DateRanges).optional(), }); +export const getDockerDetailsByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required") }); +export const getDockerDetailsByIdQueryValidation = z.object({ dateRange: z.enum(DateRanges).optional() }); + // Canonical monitor shape returned by /monitors endpoints. Keep aligned with // what the controllers actually serialize. export const monitorResponseSchema = z @@ -323,6 +353,7 @@ export const monitorResponseSchema = z tags: z.array(z.string()), customUpCodes: z.array(httpStatusCode).optional(), secret: z.string().optional(), + sshPrivateKey: z.string().optional(), cpuAlertThreshold: z.number(), memoryAlertThreshold: z.number(), diskAlertThreshold: z.number(), @@ -392,3 +423,58 @@ export const uptimeDetailsResponseSchema = z.object({ }), monitorStats: monitorStatsResponseSchema.nullable(), }); + +// Keep aligned with DockerContainerInfo / DockerContainerSummary in types/network.ts. +export const dockerContainerResponseSchema = z.object({ + id: z.string(), + name: z.string(), + image: z.string(), + state: z.enum(DockerContainerStates), + status: z.string(), + health: z.enum(DockerHealthStatuses), + cpuPct: z.number().optional(), + memoryUsedBytes: z.number().optional(), + memoryLimitBytes: z.number().optional(), + memoryPct: z.number().optional(), + restartCount: z.number().optional(), + startedAt: z.string().optional(), +}); + +export const containerSummaryResponseSchema = z.object({ + total: z.number(), + running: z.number(), + stopped: z.number(), + unhealthy: z.number(), +}); + +// Keep aligned with DockerStatsBucket in domain/checks/check.type.ts. The avg fields are +// null for buckets with no values ($avg skips missing; down checks store no containerSummary). +export const dockerStatsBucketResponseSchema = z.object({ + _id: z.string(), + avgResponseTime: z.number().nullable(), + upCount: z.number(), + totalCount: z.number(), + avgRunning: z.number().nullable(), + avgTotal: z.number().nullable(), + avgUnhealthy: z.number().nullable(), +}); + +// Response of GET /monitors/docker/details/{monitorId}. Keep aligned with +// DockerDetailsResult in domain/monitors/monitor.type.ts; the monitor here is the +// repository's domain entity, which serializes `id` rather than `_id`. +export const dockerDetailsResponseSchema = z.object({ + monitor: monitorResponseSchema.omit({ _id: true }).extend({ id: z.string() }), + stats: z.object({ + aggregateData: z.object({ totalChecks: z.number() }), + upChecks: z.object({ totalChecks: z.number() }), + aggregate: z.array(dockerStatsBucketResponseSchema), + latest: z + .object({ + containers: z.array(dockerContainerResponseSchema), + summary: containerSummaryResponseSchema.optional(), + checkedAt: z.string(), + }) + .nullable(), + }), + monitorStats: monitorStatsResponseSchema.nullable(), +}); From c8826d90d5f0ae3c6ac9f4f6da1ca64e519fb209 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 13:59:54 -0700 Subject: [PATCH 10/13] update openapi --- server/openapi/routes/monitor.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/openapi/routes/monitor.ts b/server/openapi/routes/monitor.ts index ddb207eb8..fb388c909 100644 --- a/server/openapi/routes/monitor.ts +++ b/server/openapi/routes/monitor.ts @@ -16,6 +16,9 @@ import { importMonitorsBodyValidation, getHardwareDetailsByIdParamValidation, getHardwareDetailsByIdQueryValidation, + getDockerDetailsByIdParamValidation, + getDockerDetailsByIdQueryValidation, + dockerDetailsResponseSchema, monitorResponseSchema, uptimeDetailsResponseSchema, } from "@/api/validation/monitorValidation.js"; @@ -105,6 +108,16 @@ registry.registerPath({ responses: { "200": okUnknown, ...standardErrors }, }); +registry.registerPath({ + method: "get", + path: "/monitors/docker/details/{monitorId}", + tags, + summary: "Get Docker host details for a monitor", + security: bearer, + request: { params: getDockerDetailsByIdParamValidation, query: getDockerDetailsByIdQueryValidation }, + responses: { "200": okJson(dockerDetailsResponseSchema), ...standardErrors }, +}); + registry.registerPath({ method: "get", path: "/monitors/pagespeed/details/{monitorId}", From 560d64a482436946ac62c1c50164d07f4170f235 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 14:00:34 -0700 Subject: [PATCH 11/13] add controller method for docker monitors --- .../src/api/controllers/monitorController.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/server/src/api/controllers/monitorController.ts b/server/src/api/controllers/monitorController.ts index f05e0df5c..d636830e2 100644 --- a/server/src/api/controllers/monitorController.ts +++ b/server/src/api/controllers/monitorController.ts @@ -17,6 +17,8 @@ import { getUptimeDetailsByIdQueryValidation, importMonitorsBodyValidation, bulkPauseMonitorBodyValidation, + getDockerDetailsByIdParamValidation, + getDockerDetailsByIdQueryValidation, } from "@/api/validation/monitorValidation.js"; import sslChecker from "ssl-checker"; import * as whoiser from "whoiser"; @@ -31,6 +33,7 @@ export interface IMonitorController { getUptimeDetailsById: RequestHandler; getHardwareDetailsById: RequestHandler; getPageSpeedDetailsById: RequestHandler; + getDockerDetailsById: RequestHandler; getGeoChecksByMonitorId: RequestHandler; getMonitorById: RequestHandler; createMonitor: RequestHandler; @@ -149,6 +152,27 @@ class MonitorController implements IMonitorController { }); }); + getDockerDetailsById = catchAsync(async (req: Request, res: Response) => { + const validatedParams = getDockerDetailsByIdParamValidation.parse(req.params); + const validatedQuery = getDockerDetailsByIdQueryValidation.parse(req.query); + + const monitorId = validatedParams.monitorId; + const dateRange = validatedQuery.dateRange || "recent"; + const teamId = requireTeamId(req.user?.teamId); + + const data = await this.monitorService.getDockerDetailsById({ + teamId, + monitorId, + dateRange, + }); + + return res.status(200).json({ + success: true, + msg: "Docker details retrieved successfully", + data: data, + }); + }); + getGeoChecksByMonitorId = catchAsync(async (req: Request, res: Response) => { const validatedParams = getMonitorByIdParamValidation.parse(req.params); const validatedQuery = getMonitorByIdQueryValidation.parse(req.query); From e60bfd18ff7aa177bc996f9c3075c9b0bc67df4f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 14:03:27 -0700 Subject: [PATCH 12/13] add route --- server/openapi.json | 540 +++++++++++++++++++++++++ server/src/api/routes/monitorRoutes.ts | 3 + 2 files changed, 543 insertions(+) diff --git a/server/openapi.json b/server/openapi.json index c2408b580..de869c314 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -422,6 +422,9 @@ "secret": { "type": "string" }, + "sshPrivateKey": { + "type": "string" + }, "cpuAlertThreshold": { "type": "number" }, @@ -637,6 +640,9 @@ "secret": { "type": "string" }, + "sshPrivateKey": { + "type": "string" + }, "cpuAlertThreshold": { "type": "number" }, @@ -1648,6 +1654,24 @@ }, "performance": { "type": "number" + }, + "containerSummary": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "running": { + "type": "number" + }, + "stopped": { + "type": "number" + }, + "unhealthy": { + "type": "number" + } + }, + "required": ["total", "running", "stopped", "unhealthy"] } }, "required": ["id", "status", "responseTime", "statusCode", "message", "createdAt"], @@ -6936,6 +6960,513 @@ } } }, + "/monitors/docker/details/{monitorId}": { + "get": { + "tags": ["monitors"], + "summary": "Get Docker host details for a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + }, + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"] + }, + "required": false, + "name": "dateRange", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "monitor": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] + }, + "url": { + "type": "string" + }, + "port": { + "type": "number" + }, + "isActive": { + "type": "boolean" + }, + "interval": { + "type": "number" + }, + "status": { + "type": "string", + "enum": ["up", "down", "paused", "initializing", "maintenance", "breached"] + }, + "statusWindowSize": { + "type": "number" + }, + "statusWindowThreshold": { + "type": "number" + }, + "ignoreTlsErrors": { + "type": "boolean" + }, + "proxyMode": { + "type": "string", + "enum": ["inherit", "none", "custom"] + }, + "proxyId": { + "type": "string" + }, + "useAdvancedMatching": { + "type": "boolean" + }, + "jsonPath": { + "type": "string" + }, + "expectedValue": { + "type": "string" + }, + "matchMethod": { + "type": "string", + "enum": ["equal", "include", "regex"] + }, + "method": { + "type": "string", + "enum": ["GET", "HEAD"] + }, + "notifications": { + "type": "array", + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "customUpCodes": { + "type": "array", + "items": { + "type": "number" + } + }, + "secret": { + "type": "string" + }, + "sshPrivateKey": { + "type": "string" + }, + "cpuAlertThreshold": { + "type": "number" + }, + "memoryAlertThreshold": { + "type": "number" + }, + "diskAlertThreshold": { + "type": "number" + }, + "tempAlertThreshold": { + "type": "number" + }, + "selectedDisks": { + "type": "array", + "items": { + "type": "string" + } + }, + "gameId": { + "type": "string" + }, + "grpcServiceName": { + "type": "string" + }, + "group": { + "type": "string", + "nullable": true + }, + "geoCheckEnabled": { + "type": "boolean" + }, + "geoCheckLocations": { + "type": "array", + "items": { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + } + }, + "geoCheckInterval": { + "type": "number" + }, + "dnsServer": { + "type": "string" + }, + "dnsRecordType": { + "type": "string", + "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] + }, + "teamId": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "lastEvaluatedAt": { + "type": "number" + }, + "id": { + "type": "string" + } + }, + "required": [ + "name", + "type", + "url", + "isActive", + "interval", + "status", + "statusWindowSize", + "statusWindowThreshold", + "ignoreTlsErrors", + "proxyMode", + "useAdvancedMatching", + "method", + "notifications", + "tags", + "cpuAlertThreshold", + "memoryAlertThreshold", + "diskAlertThreshold", + "tempAlertThreshold", + "selectedDisks", + "geoCheckEnabled", + "geoCheckLocations", + "geoCheckInterval", + "teamId", + "userId", + "createdAt", + "updatedAt", + "lastEvaluatedAt", + "id" + ], + "additionalProperties": { + "nullable": true + } + }, + "stats": { + "type": "object", + "properties": { + "aggregateData": { + "type": "object", + "properties": { + "totalChecks": { + "type": "number" + } + }, + "required": ["totalChecks"] + }, + "upChecks": { + "type": "object", + "properties": { + "totalChecks": { + "type": "number" + } + }, + "required": ["totalChecks"] + }, + "aggregate": { + "type": "array", + "items": { + "type": "object", + "properties": { + "_id": { + "type": "string" + }, + "avgResponseTime": { + "type": "number", + "nullable": true + }, + "upCount": { + "type": "number" + }, + "totalCount": { + "type": "number" + }, + "avgRunning": { + "type": "number", + "nullable": true + }, + "avgTotal": { + "type": "number", + "nullable": true + }, + "avgUnhealthy": { + "type": "number", + "nullable": true + } + }, + "required": ["_id", "avgResponseTime", "upCount", "totalCount", "avgRunning", "avgTotal", "avgUnhealthy"] + } + }, + "latest": { + "type": "object", + "nullable": true, + "properties": { + "containers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "image": { + "type": "string" + }, + "state": { + "type": "string", + "enum": ["created", "running", "paused", "restarting", "removing", "exited", "dead"] + }, + "status": { + "type": "string" + }, + "health": { + "type": "string", + "enum": ["healthy", "unhealthy", "starting", "none"] + }, + "cpuPct": { + "type": "number" + }, + "memoryUsedBytes": { + "type": "number" + }, + "memoryLimitBytes": { + "type": "number" + }, + "memoryPct": { + "type": "number" + }, + "restartCount": { + "type": "number" + }, + "startedAt": { + "type": "string" + } + }, + "required": ["id", "name", "image", "state", "status", "health"] + } + }, + "summary": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "running": { + "type": "number" + }, + "stopped": { + "type": "number" + }, + "unhealthy": { + "type": "number" + } + }, + "required": ["total", "running", "stopped", "unhealthy"] + }, + "checkedAt": { + "type": "string" + } + }, + "required": ["containers", "checkedAt"] + } + }, + "required": ["aggregateData", "upChecks", "aggregate", "latest"] + }, + "monitorStats": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "string" + }, + "monitorId": { + "type": "string" + }, + "avgResponseTime": { + "type": "number" + }, + "maxResponseTime": { + "type": "number" + }, + "totalChecks": { + "type": "number" + }, + "totalUpChecks": { + "type": "number" + }, + "totalDownChecks": { + "type": "number" + }, + "uptimePercentage": { + "type": "number" + }, + "lastCheckTimestamp": { + "type": "number" + }, + "lastResponseTime": { + "type": "number" + }, + "timeOfLastFailure": { + "type": "number" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "monitorId", + "avgResponseTime", + "maxResponseTime", + "totalChecks", + "totalUpChecks", + "totalDownChecks", + "uptimePercentage", + "lastCheckTimestamp", + "lastResponseTime", + "createdAt", + "updatedAt" + ] + } + }, + "required": ["monitor", "stats", "monitorStats"] + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, "/monitors/pagespeed/details/{monitorId}": { "get": { "tags": ["monitors"], @@ -7860,6 +8391,9 @@ "secret": { "type": "string" }, + "sshPrivateKey": { + "type": "string" + }, "jsonPath": { "anyOf": [ { @@ -8553,6 +9087,9 @@ "secret": { "type": "string" }, + "sshPrivateKey": { + "type": "string" + }, "cpuAlertThreshold": { "type": "number", "default": 100 @@ -9083,6 +9620,9 @@ "secret": { "type": "string" }, + "sshPrivateKey": { + "type": "string" + }, "ignoreTlsErrors": { "type": "boolean" }, diff --git a/server/src/api/routes/monitorRoutes.ts b/server/src/api/routes/monitorRoutes.ts index 4cd90a9a0..6da47f2a7 100644 --- a/server/src/api/routes/monitorRoutes.ts +++ b/server/src/api/routes/monitorRoutes.ts @@ -18,6 +18,9 @@ export const createMonitorRoutes = (monitorController: IMonitorController): Rout // PageSpeed routes router.get("/pagespeed/details/:monitorId", monitorController.getPageSpeedDetailsById); + // Docker routes + router.get("/docker/details/:monitorId", monitorController.getDockerDetailsById); + // Geo checks routes router.get("/:monitorId/geo-checks", monitorController.getGeoChecksByMonitorId); From e964234f544ca34cc57954e3455a8059e2b3aa13 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 14:42:46 -0700 Subject: [PATCH 13/13] add tests and migration --- .../0012_migrateDockerMonitorUrls.ts | 36 ++++++++ server/src/db/migration/index.ts | 2 + .../test/unit/services/checkService.test.ts | 27 ++++++ .../test/unit/services/monitorService.test.ts | 92 ++++++++++++++++++- .../unit/validation/monitorValidation.test.ts | 75 +++++++++++++++ 5 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 server/src/db/migration/0012_migrateDockerMonitorUrls.ts diff --git a/server/src/db/migration/0012_migrateDockerMonitorUrls.ts b/server/src/db/migration/0012_migrateDockerMonitorUrls.ts new file mode 100644 index 000000000..75f3d5acf --- /dev/null +++ b/server/src/db/migration/0012_migrateDockerMonitorUrls.ts @@ -0,0 +1,36 @@ +import { MonitorModel } from "../../domain/monitors/monitor.model.js"; +import type { ILogger } from "@/utils/logger.js"; + +/** + * Rewrite docker monitor urls from the old container-name semantics to host urls. + * + * The old docker provider interpreted monitor.url as a container name/ID and only + * ever talked to the local socket, so every pre-existing docker monitor's host is + * definitionally unix:///var/run/docker.sock. The container name is preserved in + * the description. Urls already in host form (unix://, ssh://, or an absolute + * socket path) are left untouched, which makes the migration idempotent. + */ +export async function migrateDockerMonitorUrls(logger: ILogger): Promise { + const SERVICE_NAME = "Migration:MigrateDockerMonitorUrls"; + + try { + logger.info({ service: SERVICE_NAME, message: "Starting docker monitor url migration" }); + + const result = await MonitorModel.updateMany({ type: "docker", url: { $not: /^((unix|ssh):\/\/|\/)/ } }, [ + { + $set: { + description: { + $concat: [{ $ifNull: ["$description", ""] }, " (was container: ", "$url", ")"], + }, + url: "unix:///var/run/docker.sock", + }, + }, + ]); + + logger.info({ service: SERVICE_NAME, message: `Migrated ${result.modifiedCount} docker monitors to host urls` }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ service: SERVICE_NAME, message: `Error migrating docker monitor urls: ${errorMessage}` }); + throw error; + } +} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index 94829241d..c4233948c 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -10,6 +10,7 @@ import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvalua import { recomputeResponseTimeFromTimings } from "./0009_recomputeResponseTimeFromTimings.js"; import { slimRecentChecks } from "./0010_slimRecentChecks.js"; import { backfillMonitorProxyMode } from "./0011_backfillMonitorProxyMode.js"; +import { migrateDockerMonitorUrls } from "./0012_migrateDockerMonitorUrls.js"; import type { ILogger } from "@/utils/logger.js"; type MigrationEntry = { @@ -29,6 +30,7 @@ const migrations: MigrationEntry[] = [ { name: "0009_recomputeResponseTimeFromTimings", execute: recomputeResponseTimeFromTimings }, { name: "0010_slimRecentChecks", execute: slimRecentChecks }, { name: "0011_backfillMonitorProxyMode", execute: backfillMonitorProxyMode }, + { name: "0012_migrateDockerMonitorUrls", execute: migrateDockerMonitorUrls }, ]; const runMigrations = async (logger: ILogger) => { diff --git a/server/test/unit/services/checkService.test.ts b/server/test/unit/services/checkService.test.ts index 80b941b6f..4ced65947 100644 --- a/server/test/unit/services/checkService.test.ts +++ b/server/test/unit/services/checkService.test.ts @@ -252,6 +252,33 @@ describe("CheckService", () => { expect(check!.errors).toBeUndefined(); }); }); + + describe("docker type", () => { + it("extracts containers and containerSummary from payload", () => { + const payload = { + containers: [ + { id: "abc", name: "web", image: "nginx:latest", state: "running", status: "Up 3 hours", health: "healthy" }, + { id: "def", name: "db", image: "mongo:8.0", state: "exited", status: "Exited (0)", health: "none" }, + ], + summary: { total: 2, running: 1, stopped: 1, unhealthy: 0 }, + } as any; + const { service } = createService(); + const check = service.toCheck(makeStatusResponse({ type: "docker", payload } as any)); + + expect(check!.containers).toHaveLength(2); + expect(check!.containers![0]).toMatchObject({ id: "abc", state: "running", health: "healthy" }); + expect(check!.containerSummary).toEqual({ total: 2, running: 1, stopped: 1, unhealthy: 0 }); + }); + + it("handles a null payload gracefully (down check)", () => { + const { service } = createService(); + const check = service.toCheck(makeStatusResponse({ type: "docker", payload: null } as any)); + + expect(check).toBeDefined(); + expect(check!.containers).toBeUndefined(); + expect(check!.containerSummary).toBeUndefined(); + }); + }); }); // ── toStatusResponse round-trip (Phase 2 §5 guard) ───────────────────────── diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index fcfdf533d..c725fcd10 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -237,8 +237,8 @@ describe("MonitorService", () => { expect(result.monitorData.monitor).toMatchObject({ id: MONITOR_ID }); }); - it("supports all uptime monitor types: ping, docker, port, game, grpc, websocket", async () => { - for (const monitorType of ["ping", "docker", "port", "game", "grpc", "websocket"] as const) { + it("supports all uptime monitor types: ping, port, game, grpc, websocket, dns", async () => { + for (const monitorType of ["ping", "port", "game", "grpc", "websocket", "dns"] as const) { const monitorsRepository = createMonitorsRepositoryMock(); const checksRepository = createChecksRepositoryMock(); const monitorStatsRepository = createMonitorStatsRepositoryMock(); @@ -314,6 +314,27 @@ describe("MonitorService", () => { ); }); + it("throws 400 for unsupported monitor type (docker)", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const monitor = makeMonitor({ type: "docker" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ + monitorType: "docker", + aggregateData: { totalChecks: 0 }, + upChecks: { totalChecks: 0 }, + aggregate: [], + latest: null, + }); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(null); + + const { service } = createService({ monitorsRepository, checksRepository, monitorStatsRepository }); + await expect(service.getUptimeDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" })).rejects.toThrow( + "monitors are not supported for uptime details" + ); + }); + it("uses different date ranges correctly", async () => { for (const dateRange of ["recent", "day", "week", "month", "all"] as const) { const monitorsRepository = createMonitorsRepositoryMock(); @@ -398,6 +419,73 @@ describe("MonitorService", () => { }); }); + describe("getDockerDetailsById", () => { + it("returns docker details for a docker monitor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const monitor = makeMonitor({ type: "docker" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ + monitorType: "docker", + aggregateData: { totalChecks: 10 }, + upChecks: { totalChecks: 9 }, + aggregate: [{ _id: "2024-01-01T00:00:00Z", avgResponseTime: 12, upCount: 9, totalCount: 10, avgRunning: 3, avgTotal: 4, avgUnhealthy: 0 }], + latest: { + containers: [], + summary: { total: 4, running: 3, stopped: 1, unhealthy: 0 }, + checkedAt: "2024-01-01T00:00:00Z", + }, + }); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue({ monitorId: MONITOR_ID }); + + const { service } = createService({ monitorsRepository, checksRepository, monitorStatsRepository }); + const result = await service.getDockerDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" }); + + expect(result.monitor).toMatchObject({ id: MONITOR_ID }); + expect(result.stats).toHaveProperty("aggregateData"); + expect(result.stats).toHaveProperty("upChecks"); + expect(result.stats).toHaveProperty("aggregate"); + expect(result.stats).toHaveProperty("latest"); + expect(result.stats.latest?.summary).toMatchObject({ total: 4, running: 3, stopped: 1, unhealthy: 0 }); + expect(result.monitorStats).toMatchObject({ monitorId: MONITOR_ID }); + }); + + it("throws 404 when monitor not found", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(null); + const { service } = createService({ monitorsRepository }); + + await expect(service.getDockerDetailsById({ teamId: TEAM_ID, monitorId: "missing", dateRange: "recent" })).rejects.toThrow( + "Monitor with ID missing not found." + ); + }); + + it("throws 400 when monitor type is not docker", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitor = makeMonitor({ type: "http" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + const { service } = createService({ monitorsRepository }); + + await expect(service.getDockerDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" })).rejects.toThrow( + "monitors are not supported for docker details" + ); + }); + + it("throws 500 when checksData monitorType is not docker", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitor = makeMonitor({ type: "docker" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ monitorType: "http" }); + const { service } = createService({ monitorsRepository, checksRepository }); + + await expect(service.getDockerDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" })).rejects.toThrow( + "Unable to load docker stats for this monitor" + ); + }); + }); + describe("getPageSpeedDetailsById", () => { it("returns pagespeed details for a pagespeed monitor", async () => { const monitorsRepository = createMonitorsRepositoryMock(); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index ea2257072..2b6f994d7 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -565,3 +565,78 @@ describe("monitorValidation — proxy fields", () => { }); }); }); + +describe("monitorValidation — Docker host url", () => { + const baseDockerBody = { + name: "Docker host check", + type: "docker" as const, + url: "unix:///var/run/docker.sock", + }; + const PRIVATE_KEY = "-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n-----END OPENSSH PRIVATE KEY-----"; + + describe("createMonitorBodyValidation", () => { + it("accepts the default unix socket url", () => { + const parsed = createMonitorBodyValidation.parse(baseDockerBody); + expect(parsed.url).toBe("unix:///var/run/docker.sock"); + }); + + it("accepts a bare absolute socket path", () => { + const parsed = createMonitorBodyValidation.parse({ ...baseDockerBody, url: "/run/docker.sock" }); + expect(parsed.url).toBe("/run/docker.sock"); + }); + + it("accepts an ssh url with a private key and retains the key", () => { + for (const url of ["ssh://deploy@host", "ssh://deploy@host:2222"]) { + const parsed = createMonitorBodyValidation.parse({ ...baseDockerBody, url, sshPrivateKey: PRIVATE_KEY }); + expect(parsed.sshPrivateKey).toBe(PRIVATE_KEY); + } + }); + + it("rejects container names and unsupported engine urls", () => { + for (const badUrl of ["my-container", "tcp://host:2375", "https://host", "unix://relative/path", ""]) { + expect(() => createMonitorBodyValidation.parse({ ...baseDockerBody, url: badUrl })).toThrow(); + } + }); + + it("rejects an ssh url without a user", () => { + expect(() => createMonitorBodyValidation.parse({ ...baseDockerBody, url: "ssh://host", sshPrivateKey: PRIVATE_KEY })).toThrow(); + }); + + it("rejects an ssh url without a private key, attributing the issue to sshPrivateKey", () => { + const result = createMonitorBodyValidation.safeParse({ ...baseDockerBody, url: "ssh://deploy@host" }); + expect(result.success).toBe(false); + expect(result.error?.issues.some((issue) => issue.path.includes("sshPrivateKey"))).toBe(true); + }); + + it("does not apply docker url rules to other monitor types", () => { + const parsed = createMonitorBodyValidation.parse({ name: "HTTP check", type: "http", url: "https://example.com" }); + expect(parsed.sshPrivateKey).toBeUndefined(); + }); + }); + + describe("editMonitorBodyValidation", () => { + it("allows a docker edit without a url (partial edit)", () => { + const parsed = editMonitorBodyValidation.parse({ type: "docker", name: "renamed" }); + expect(parsed.url).toBeUndefined(); + }); + + it("rejects a docker edit with an invalid url", () => { + expect(() => editMonitorBodyValidation.parse({ type: "docker", url: "my-container" })).toThrow(); + }); + + it("rejects a docker edit switching to ssh without a key", () => { + expect(() => editMonitorBodyValidation.parse({ type: "docker", url: "ssh://deploy@host" })).toThrow(); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("accepts an imported docker monitor with a socket url", () => { + const parsed = importMonitorsBodyValidation.parse({ monitors: [baseDockerBody] }); + expect(parsed.monitors[0].url).toBe("unix:///var/run/docker.sock"); + }); + + it("rejects an imported docker monitor with a container-name url", () => { + expect(() => importMonitorsBodyValidation.parse({ monitors: [{ ...baseDockerBody, url: "my-container" }] })).toThrow(); + }); + }); +});