diff --git a/server/openapi.json b/server/openapi.json index de869c314..cd6a3466d 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -7467,6 +7467,425 @@ } } }, + "/monitors/docker/details/{monitorId}/containers/{containerName}": { + "get": { + "tags": ["monitors"], + "summary": "Get details for one container on a Docker host monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "containerName", + "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": { + "aggregate": { + "type": "array", + "items": { + "type": "object", + "properties": { + "_id": { + "type": "string" + }, + "avgCpuPct": { + "type": "number", + "nullable": true + }, + "avgMemoryUsedBytes": { + "type": "number", + "nullable": true + }, + "avgMemoryPct": { + "type": "number", + "nullable": true + }, + "minRestartCount": { + "type": "number", + "nullable": true + }, + "maxRestartCount": { + "type": "number", + "nullable": true + } + }, + "required": ["_id", "avgCpuPct", "avgMemoryUsedBytes", "avgMemoryPct", "minRestartCount", "maxRestartCount"] + } + }, + "restartsInRange": { + "type": "number" + }, + "latest": { + "type": "object", + "nullable": true, + "properties": { + "container": { + "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"] + }, + "checkedAt": { + "type": "string" + } + }, + "required": ["container", "checkedAt"] + } + }, + "required": ["aggregate", "restartsInRange", "latest"] + } + }, + "required": ["monitor", "stats"] + } + }, + "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"], diff --git a/server/openapi/routes/monitor.ts b/server/openapi/routes/monitor.ts index fb388c909..f7d92c94e 100644 --- a/server/openapi/routes/monitor.ts +++ b/server/openapi/routes/monitor.ts @@ -18,7 +18,10 @@ import { getHardwareDetailsByIdQueryValidation, getDockerDetailsByIdParamValidation, getDockerDetailsByIdQueryValidation, + getDockerContainerNameParamValidation, + getDockerContainerByNameQueryValidation, dockerDetailsResponseSchema, + dockerContainerDetailsResponseSchema, monitorResponseSchema, uptimeDetailsResponseSchema, } from "@/api/validation/monitorValidation.js"; @@ -118,6 +121,16 @@ registry.registerPath({ responses: { "200": okJson(dockerDetailsResponseSchema), ...standardErrors }, }); +registry.registerPath({ + method: "get", + path: "/monitors/docker/details/{monitorId}/containers/{containerName}", + tags, + summary: "Get details for one container on a Docker host monitor", + security: bearer, + request: { params: getDockerContainerNameParamValidation, query: getDockerContainerByNameQueryValidation }, + responses: { "200": okJson(dockerContainerDetailsResponseSchema), ...standardErrors }, +}); + registry.registerPath({ method: "get", path: "/monitors/pagespeed/details/{monitorId}", diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index aabf6a957..3b94f6989 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -484,3 +484,29 @@ export const dockerDetailsResponseSchema = z.object({ }), monitorStats: monitorStatsResponseSchema.nullable(), }); + +// Keep aligned with DockerContainerStatsBucket in domain/checks/check.type.ts. +export const dockerContainerStatsBucketResponseSchema = z.object({ + _id: z.string(), + avgCpuPct: z.number().nullable(), + avgMemoryUsedBytes: z.number().nullable(), + avgMemoryPct: z.number().nullable(), + minRestartCount: z.number().nullable(), + maxRestartCount: z.number().nullable(), +}); + +// Response of GET /monitors/docker/details/{monitorId}/containers/{containerName}. Keep +// aligned with DockerContainerDetailsResult in domain/monitors/monitor.type.ts. +export const dockerContainerDetailsResponseSchema = z.object({ + monitor: monitorResponseSchema.omit({ _id: true }).extend({ id: z.string() }), + stats: z.object({ + aggregate: z.array(dockerContainerStatsBucketResponseSchema), + restartsInRange: z.number(), + latest: z + .object({ + container: dockerContainerResponseSchema, + checkedAt: z.string(), + }) + .nullable(), + }), +}); diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index c725fcd10..79753e41c 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -29,6 +29,7 @@ const createMonitorsRepositoryMock = () => const createChecksRepositoryMock = () => ({ findByDateRangeAndMonitorId: jest.fn(), + findDockerContainerChecks: jest.fn(), deleteByMonitorId: jest.fn(), }) as unknown as IChecksRepository; @@ -486,6 +487,113 @@ describe("MonitorService", () => { }); }); + describe("getDockerContainerByName", () => { + const makeBucket = (overrides: Partial> = {}) => ({ + _id: "2024-01-01T00:00:00Z", + avgCpuPct: 12.5, + avgMemoryUsedBytes: 1024, + avgMemoryPct: 50, + minRestartCount: 0, + maxRestartCount: 0, + ...overrides, + }); + + it("returns container details for a docker monitor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitor = makeMonitor({ type: "docker" }); + const container = { id: "abc", name: "checkmate-mongo", image: "mongo:8.0", state: "running", status: "Up 3 hours", health: "healthy" }; + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findDockerContainerChecks as jest.Mock).mockResolvedValue({ + aggregate: [makeBucket()], + latest: { container, checkedAt: "2024-01-01T00:00:00Z" }, + }); + + const { service } = createService({ monitorsRepository, checksRepository }); + const result = await service.getDockerContainerByName({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + containerName: "checkmate-mongo", + dateRange: "recent", + }); + + expect(checksRepository.findDockerContainerChecks).toHaveBeenCalledWith(MONITOR_ID, "checkmate-mongo", "recent"); + expect(result.monitor).toMatchObject({ id: MONITOR_ID }); + expect(result.stats.latest?.container).toMatchObject({ name: "checkmate-mongo" }); + expect(result.stats.restartsInRange).toBe(0); + }); + + it("sums restarts within and between buckets", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + (checksRepository.findDockerContainerChecks as jest.Mock).mockResolvedValue({ + aggregate: [ + makeBucket({ _id: "01", minRestartCount: 0, maxRestartCount: 2 }), + makeBucket({ _id: "02", minRestartCount: 3, maxRestartCount: 3 }), + makeBucket({ _id: "03", minRestartCount: null, maxRestartCount: null }), + makeBucket({ _id: "04", minRestartCount: 3, maxRestartCount: 4 }), + ], + latest: null, + }); + + const { service } = createService({ monitorsRepository, checksRepository }); + const result = await service.getDockerContainerByName({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + containerName: "checkmate-mongo", + dateRange: "day", + }); + + // 2 within bucket 01, +1 between 01 and 02, +1 within 04 (null bucket skipped) + expect(result.stats.restartsInRange).toBe(4); + }); + + it("counts a restart-count drop as a recreate", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + (checksRepository.findDockerContainerChecks as jest.Mock).mockResolvedValue({ + aggregate: [ + makeBucket({ _id: "01", minRestartCount: 5, maxRestartCount: 5 }), + makeBucket({ _id: "02", minRestartCount: 1, maxRestartCount: 1 }), + ], + latest: null, + }); + + const { service } = createService({ monitorsRepository, checksRepository }); + const result = await service.getDockerContainerByName({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + containerName: "checkmate-mongo", + dateRange: "day", + }); + + // counter dropped 5 -> 1: the recreate itself plus the 1 restart the new counter shows + expect(result.stats.restartsInRange).toBe(2); + }); + + 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.getDockerContainerByName({ teamId: TEAM_ID, monitorId: "missing", containerName: "checkmate-mongo", dateRange: "recent" }) + ).rejects.toThrow("Monitor with ID missing not found."); + }); + + it("throws 400 when monitor type is not docker", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "http" })); + const { service } = createService({ monitorsRepository }); + + await expect( + service.getDockerContainerByName({ teamId: TEAM_ID, monitorId: MONITOR_ID, containerName: "checkmate-mongo", dateRange: "recent" }) + ).rejects.toThrow("monitors are not supported for docker container details"); + }); + }); + describe("getPageSpeedDetailsById", () => { it("returns pagespeed details for a pagespeed monitor", async () => { const monitorsRepository = createMonitorsRepositoryMock();