diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 651cf786d..357cb4aaa 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -220,6 +220,8 @@ export const createMonitorBodyValidation = z geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), dockerLogsEnabled: z.boolean().optional(), + dockerAlertOnStopped: z.boolean().optional(), + dockerAlertOnUnhealthy: z.boolean().optional(), dockerTlsCa: z.union([z.string(), z.literal("")]).optional(), dockerTlsCert: z.union([z.string(), z.literal("")]).optional(), dockerTlsKey: z.union([z.string(), z.literal("")]).optional(), @@ -350,6 +352,8 @@ const importedMonitorSchema = z geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), geoCheckInterval: z.number().min(300000).default(300000), dockerLogsEnabled: z.boolean().default(false), + dockerAlertOnStopped: z.boolean().default(false), + dockerAlertOnUnhealthy: z.boolean().default(false), dnsServer: dnsServerValidation.optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), createdAt: z.string().optional(), diff --git a/server/src/domain/checks/check.model.ts b/server/src/domain/checks/check.model.ts index 1f92fce3c..a4e202d40 100644 --- a/server/src/domain/checks/check.model.ts +++ b/server/src/domain/checks/check.model.ts @@ -220,6 +220,7 @@ const dockerContainerSchema = new Schema( startedAt: { type: String }, ports: { type: [dockerContainerPortSchema], default: undefined }, mounts: { type: [dockerContainerMountSchema], default: undefined }, + exitCode: { type: Number, default: undefined }, }, { _id: false } ); diff --git a/server/src/domain/checks/check.service.ts b/server/src/domain/checks/check.service.ts index 2bc9f2a16..30db653e6 100644 --- a/server/src/domain/checks/check.service.ts +++ b/server/src/domain/checks/check.service.ts @@ -135,6 +135,17 @@ export class CheckService implements ICheckService { return check; }; + private toPayload = (check: Check): HardwareStatusPayload | DockerStatusPayload | undefined => { + switch (check.metadata.type) { + case "hardware": + return { data: { cpu: check.cpu, memory: check.memory, disk: check.disk, host: check.host, net: check.net } } as HardwareStatusPayload; + case "docker": + return check.containers ? { containers: check.containers, summary: check.containerSummary } : undefined; + default: + return undefined; + } + }; + toStatusResponse = (check: Check) => { const statusResponse: MonitorStatusResponse = { monitorId: check.metadata.monitorId, @@ -145,10 +156,7 @@ export class CheckService implements ICheckService { message: check.message, responseTime: check.responseTime, // re-nest hardware metrics so updateMonitorStatus / extractThresholdBreaches see payload.data - payload: - check.metadata.type === "hardware" - ? ({ data: { cpu: check.cpu, memory: check.memory, disk: check.disk, host: check.host, net: check.net } } as HardwareStatusPayload) - : undefined, + payload: this.toPayload(check), }; return statusResponse; }; diff --git a/server/src/domain/docker/docker.type.ts b/server/src/domain/docker/docker.type.ts index 66504e273..da2d51337 100644 --- a/server/src/domain/docker/docker.type.ts +++ b/server/src/domain/docker/docker.type.ts @@ -55,6 +55,7 @@ export interface DockerContainerInfo { startedAt?: string; // ISO date ports?: DockerContainerPort[]; mounts?: DockerContainerMount[]; + exitCode?: number; } export interface DockerContainerSummary { diff --git a/server/src/domain/monitors/monitor.model.ts b/server/src/domain/monitors/monitor.model.ts index c04107651..94a0fd676 100644 --- a/server/src/domain/monitors/monitor.model.ts +++ b/server/src/domain/monitors/monitor.model.ts @@ -307,6 +307,14 @@ const MonitorSchema = new Schema( type: Boolean, default: false, }, + dockerAlertOnStopped: { + type: Boolean, + default: false, + }, + dockerAlertOnUnhealthy: { + type: Boolean, + default: false, + }, dockerTlsCa: { type: String, }, diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index 385dcebab..882bd5ccb 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -468,6 +468,8 @@ class MongoMonitorsRepository implements IMonitorsRepository { geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckInterval: doc.geoCheckInterval ?? 300000, dockerLogsEnabled: doc.dockerLogsEnabled ?? false, + dockerAlertOnStopped: doc.dockerAlertOnStopped ?? false, + dockerAlertOnUnhealthy: doc.dockerAlertOnUnhealthy ?? false, dockerTlsCa: doc.dockerTlsCa ?? undefined, dockerTlsCert: doc.dockerTlsCert ?? undefined, dockerTlsKeySet: doc.dockerTlsKeySet ?? false, diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index d5519af0e..38c0338eb 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -134,6 +134,8 @@ export interface Monitor { geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; dockerLogsEnabled?: boolean; + dockerAlertOnStopped?: boolean; + dockerAlertOnUnhealthy?: boolean; dockerTlsCa?: string; dockerTlsCert?: string; // EncryptionService ciphertext. Normally excluded diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index 39011a13b..fa5f3c9e5 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -42,6 +42,8 @@ const DOCKER_LOG_TRUNCATION_MARKER = " …[truncated]"; const DOCKER_LOG_TS_REGEX = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z /; const DOCKER_TLS_DEFAULT_PORT = 2376; const CAPTURE_FATAL_ERROR_METRICS = new Set(["docker.client", "docker.container.list"]); +const EXITED_STATUS = /^Exited \((\d+)\)/; +const HEALTH_STATUS = /\((healthy|unhealthy|health: starting)\)$/; export interface DockerError extends Error { statusCode?: number; @@ -140,6 +142,17 @@ export class DockerProvider implements IStatusProvider { return status && (DockerHealthStatuses as readonly string[]).includes(status) ? (status as DockerHealthStatus) : "none"; } + private exitCodeFromStatus = (status: string): number | undefined => { + const match = EXITED_STATUS.exec(status); + return match ? Number(match[1]) : undefined; + }; + + private healthFromStatus = (status: string): DockerHealthStatus => { + const match = HEALTH_STATUS.exec(status); + if (!match) return "none"; + return match[1] === "health: starting" ? "starting" : match[1]; + }; + private toContainerSummary(containers: DockerContainerInfo[]): DockerContainerSummary { return { total: containers.length, @@ -273,7 +286,10 @@ export class DockerProvider implements IStatusProvider { info.health = this.toHealthStatus(inspectResult.value.State?.Health?.Status); info.ports = this.toPorts(inspectResult.value.NetworkSettings?.Ports); info.mounts = this.toMounts(inspectResult.value.Mounts); + info.exitCode = inspectResult.value.State.ExitCode; } else { + info.exitCode = this.exitCodeFromStatus(summary.Status); + info.health = this.healthFromStatus(summary.Status); this.logger.warn({ message: `Failed to inspect container ${info.name}`, service: SERVICE_NAME,