Merge pull request #3899 from bluewave-labs/feat/first-class-docker-monitoring
feat: first class docker monitoring
This commit is contained in:
25 files changed
+1613
-390
No files matched your search
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<number> =>
|
||||
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<DockerStatsBucket[]> =>
|
||||
CheckModel.aggregate<DockerStatsBucket>([
|
||||
{
|
||||
$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();
|
||||
@@ -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<CheckMetadata, "monitorId" | "teamId"> & {
|
||||
monitorId: Types.ObjectId;
|
||||
@@ -172,6 +173,34 @@ const auditsSchema = new Schema<CheckAudits>(
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
const dockerContainerSchema = new Schema<DockerContainerInfo>(
|
||||
{
|
||||
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<DockerContainerSummary>(
|
||||
{
|
||||
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<CheckMetadataDocument>(
|
||||
{
|
||||
monitorId: {
|
||||
@@ -268,6 +297,14 @@ const CheckSchema = new Schema<CheckDocument>(
|
||||
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<CheckDocument>("Check", CheckSchema);
|
||||
|
||||
export type { CheckDocument, CheckMetadataDocument };
|
||||
export { CheckModel };
|
||||
export { CheckModel, containerSummarySchema };
|
||||
export default CheckModel;
|
||||
@@ -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<UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult>;
|
||||
): Promise<UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult | DockerChecksResult>;
|
||||
findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise<ChecksSummary>;
|
||||
findUnevaluatedByMonitorId(monitorId: string, since: number): Promise<Check[]>;
|
||||
getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string): Promise<DailyCheckBucket[]>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -167,6 +168,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),
|
||||
};
|
||||
@@ -302,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);
|
||||
};
|
||||
|
||||
@@ -412,7 +418,7 @@ class MongoChecksRepository implements IChecksRepository {
|
||||
return totalDeleted;
|
||||
};
|
||||
private findUptimeDateRangeChecks = async (
|
||||
monitorType: Exclude<MonitorType, "hardware" | "pagespeed">,
|
||||
monitorType: Exclude<MonitorType, "hardware" | "pagespeed" | "docker">,
|
||||
monitorObjectId: mongoose.Types.ObjectId,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
@@ -628,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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MonitorType } from "@/domain/monitors/monitor.type.js";
|
||||
import { DockerContainerInfo, DockerContainerSummary } from "@/types/network.js";
|
||||
import type { Response } from "got";
|
||||
|
||||
export const CHECK_TTL_SENTINEL = 366;
|
||||
@@ -107,6 +108,8 @@ export interface Check {
|
||||
host?: CheckHostInfo;
|
||||
errors?: CheckErrorInfo[];
|
||||
capture?: CheckCaptureInfo;
|
||||
containers?: DockerContainerInfo[];
|
||||
containerSummary?: DockerContainerSummary;
|
||||
net?: CheckNetworkInterfaceInfo[];
|
||||
accessibility?: number;
|
||||
bestPractices?: number;
|
||||
@@ -147,11 +150,10 @@ export interface PageSpeedGroupedCheck {
|
||||
accessibility: number;
|
||||
bestPractices: number;
|
||||
seo: number;
|
||||
totalChecks: number;
|
||||
}
|
||||
|
||||
export interface UptimeChecksResult {
|
||||
monitorType: Exclude<MonitorType, "hardware" | "pagespeed">;
|
||||
monitorType: Exclude<MonitorType, "hardware" | "pagespeed" | "docker">;
|
||||
groupedChecks: GroupedUptimeCheck[];
|
||||
groupedUpChecks: GroupedCheck[];
|
||||
groupedDownChecks: GroupedCheck[];
|
||||
@@ -197,6 +199,8 @@ export type CheckSnapshot = Pick<
|
||||
memory?: SnapshotMemoryInfo;
|
||||
disk?: SnapshotDiskInfo[];
|
||||
host?: SnapshotHostInfo;
|
||||
// Docker Only
|
||||
containerSummary?: DockerContainerSummary;
|
||||
};
|
||||
export interface HardwareDiskStats {
|
||||
name: string;
|
||||
@@ -252,3 +256,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";
|
||||
}
|
||||
@@ -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<CheckSnapshot, "createdAt"> & { createdAt: Date };
|
||||
|
||||
@@ -113,6 +114,7 @@ const checkSnapshotSchema = new Schema<CheckSnapshotDocument>(
|
||||
seo: { type: Number },
|
||||
performance: { type: Number },
|
||||
audits: { type: snapshotAuditsSchema },
|
||||
containerSummary: { type: containerSummarySchema },
|
||||
createdAt: { type: Date, required: true },
|
||||
},
|
||||
{ _id: false, suppressReservedKeysWarning: true }
|
||||
@@ -233,6 +235,9 @@ const MonitorSchema = new Schema<MonitorDocument>(
|
||||
secret: {
|
||||
type: String,
|
||||
},
|
||||
sshPrivateKey: {
|
||||
type: String,
|
||||
},
|
||||
cpuAlertThreshold: {
|
||||
type: Number,
|
||||
default: 100,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,9 +7,10 @@ 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 } 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 +27,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
|
||||
@@ -39,6 +41,7 @@ export interface IMonitorService {
|
||||
getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise<UptimeDetailsResult>;
|
||||
getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise<HardwareDetailsResult>;
|
||||
getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise<PageSpeedDetailsResult>;
|
||||
getDockerDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise<DockerDetailsResult>;
|
||||
getGeoChecksByMonitorId(args: {
|
||||
teamId: string;
|
||||
monitorId: string;
|
||||
@@ -281,6 +284,44 @@ export class MonitorService implements IMonitorService {
|
||||
monitorStats,
|
||||
};
|
||||
};
|
||||
getDockerDetailsById = async ({
|
||||
teamId,
|
||||
monitorId,
|
||||
dateRange,
|
||||
}: {
|
||||
teamId: string;
|
||||
monitorId: string;
|
||||
dateRange: DateRange;
|
||||
}): Promise<DockerDetailsResult> => {
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -99,6 +91,7 @@ export interface Monitor {
|
||||
tags: string[];
|
||||
customUpCodes: HttpStatusCode[];
|
||||
secret?: string;
|
||||
sshPrivateKey?: string;
|
||||
cpuAlertThreshold: number;
|
||||
cpuAlertCounter: number;
|
||||
memoryAlertThreshold: number;
|
||||
@@ -161,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;
|
||||
|
||||
@@ -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<DockerStatusPayload> {
|
||||
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,170 @@ export class DockerProvider implements IStatusProvider<DockerStatusPayload> {
|
||||
return error instanceof Error && ("statusCode" in error || "reason" in error || "json" in error);
|
||||
}
|
||||
|
||||
async handle(monitor: Monitor): Promise<MonitorStatusResponse<DockerStatusPayload>> {
|
||||
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 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: ${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",
|
||||
});
|
||||
|
||||
if (!monitor.sshPrivateKey)
|
||||
throw new AppError({
|
||||
message: "SSH Docker host requires a private key",
|
||||
status: 422,
|
||||
service: SERVICE_NAME,
|
||||
method: "toDockerOptions",
|
||||
});
|
||||
return {
|
||||
protocol: "ssh",
|
||||
host,
|
||||
port: port ? Number(port) : 22,
|
||||
username,
|
||||
sshOptions: { privateKey: monitor.sshPrivateKey },
|
||||
};
|
||||
};
|
||||
|
||||
private async mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
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<DockerContainerInfo, "memoryUsedBytes" | "memoryLimitBytes" | "memoryPct"> {
|
||||
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<DockerContainerInfo> {
|
||||
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<MonitorStatusResponse<DockerStatusPayload>> => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -96,7 +96,39 @@ export interface HardwareStatusPayload {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type DockerStatusPayload = Record<string, unknown>;
|
||||
// Docker host monitoring
|
||||
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;
|
||||
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;
|
||||
|
||||
@@ -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>): 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<string, any>) => ({
|
||||
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<string, any>) => ({
|
||||
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<string, any>) => ({
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -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) ─────────────────────────
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user