add new docker payload types, promote docker type monitor to first class monitor
This commit is contained in:
1 parent
80073af80a
commit
1ca38a31c9
5 files changed
+177
-112
No files matched your search
@@ -233,6 +233,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,
|
||||
|
||||
@@ -99,6 +99,7 @@ export interface Monitor {
|
||||
tags: string[];
|
||||
customUpCodes: HttpStatusCode[];
|
||||
secret?: string;
|
||||
sshPrivateKey?: string;
|
||||
cpuAlertThreshold: number;
|
||||
cpuAlertCounter: number;
|
||||
memoryAlertThreshold: number;
|
||||
|
||||
@@ -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,161 @@ 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 Error(`Invalid Docker host URL: ${url}`);
|
||||
return { socketPath };
|
||||
}
|
||||
if (url.startsWith("/")) return { socketPath: url };
|
||||
const match = url.match(/^ssh:\/\/(?:([^@\s]+)@)?([^\s/:@]+)(?::(\d{1,5}))?\/?$/);
|
||||
if (!match) throw new AppError({ message: "Invalid Docker host URL", status: 422, service: SERVICE_NAME, method: "toDockerOptions" });
|
||||
const [, username, host, port] = match;
|
||||
if (!username)
|
||||
throw new AppError({
|
||||
message: "SSH Docker host URL requires a user: ssh://user@host",
|
||||
status: 422,
|
||||
service: SERVICE_NAME,
|
||||
method: "ToDockerOptions",
|
||||
});
|
||||
return {
|
||||
protocol: "ssh",
|
||||
host,
|
||||
port: port ? Number(port) : 22,
|
||||
username,
|
||||
sshOptions: { privateKey: monitor.sshPrivateKey },
|
||||
};
|
||||
};
|
||||
|
||||
private async mapWithConcurrency<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"];
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user