diff --git a/server/src/api/controllers/monitorController.ts b/server/src/api/controllers/monitorController.ts index d636830e2..af73d786a 100644 --- a/server/src/api/controllers/monitorController.ts +++ b/server/src/api/controllers/monitorController.ts @@ -19,6 +19,8 @@ import { bulkPauseMonitorBodyValidation, getDockerDetailsByIdParamValidation, getDockerDetailsByIdQueryValidation, + getDockerContainerNameParamValidation, + getDockerContainerByNameQueryValidation, } from "@/api/validation/monitorValidation.js"; import sslChecker from "ssl-checker"; import * as whoiser from "whoiser"; @@ -34,6 +36,7 @@ export interface IMonitorController { getHardwareDetailsById: RequestHandler; getPageSpeedDetailsById: RequestHandler; getDockerDetailsById: RequestHandler; + getDockerContainerByName: RequestHandler; getGeoChecksByMonitorId: RequestHandler; getMonitorById: RequestHandler; createMonitor: RequestHandler; @@ -173,6 +176,28 @@ class MonitorController implements IMonitorController { }); }); + getDockerContainerByName = catchAsync(async (req: Request, res: Response) => { + const validatedParams = getDockerContainerNameParamValidation.parse(req.params); + const validatedQuery = getDockerContainerByNameQueryValidation.parse(req.query); + + const { monitorId, containerName } = validatedParams; + const dateRange = validatedQuery.dateRange || "recent"; + const teamId = requireTeamId(req.user?.teamId); + + const data = await this.monitorService.getDockerContainerByName({ + teamId, + monitorId, + containerName, + dateRange, + }); + + return res.status(200).json({ + success: true, + msg: "Docker container retrieved successfully", + data, + }); + }); + getGeoChecksByMonitorId = catchAsync(async (req: Request, res: Response) => { const validatedParams = getMonitorByIdParamValidation.parse(req.params); const validatedQuery = getMonitorByIdQueryValidation.parse(req.query); diff --git a/server/src/api/routes/monitorRoutes.ts b/server/src/api/routes/monitorRoutes.ts index 6da47f2a7..5192f6619 100644 --- a/server/src/api/routes/monitorRoutes.ts +++ b/server/src/api/routes/monitorRoutes.ts @@ -20,6 +20,7 @@ export const createMonitorRoutes = (monitorController: IMonitorController): Rout // Docker routes router.get("/docker/details/:monitorId", monitorController.getDockerDetailsById); + router.get("/docker/details/:monitorId/containers/:containerName", monitorController.getDockerContainerByName); // Geo checks routes router.get("/:monitorId/geo-checks", monitorController.getGeoChecksByMonitorId); diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 48477f731..aabf6a957 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -326,6 +326,12 @@ export const getHardwareDetailsByIdQueryValidation = z.object({ export const getDockerDetailsByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required") }); export const getDockerDetailsByIdQueryValidation = z.object({ dateRange: z.enum(DateRanges).optional() }); +export const getDockerContainerNameParamValidation = z.object({ + monitorId: z.string().min(1, "Monitor ID is required"), + containerName: z.string().min(1, "Container name is required"), +}); +export const getDockerContainerByNameQueryValidation = 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 diff --git a/server/src/domain/checks/check.docker.aggregation.ts b/server/src/domain/checks/check.docker.aggregation.ts index 9edd1dcbb..37d6d8e87 100644 --- a/server/src/domain/checks/check.docker.aggregation.ts +++ b/server/src/domain/checks/check.docker.aggregation.ts @@ -1,5 +1,5 @@ import CheckModel from "@/domain/checks/check.model.js"; -import { DockerStatsBucket } from "@/domain/checks/check.type.js"; +import { DockerContainerStatsBucket, DockerStatsBucket } from "@/domain/checks/check.type.js"; import mongoose from "mongoose"; type DateRange = { start: Date; end: Date }; @@ -52,3 +52,43 @@ export const getDockerLatestCheck = async (monitorId: string) => .sort({ createdAt: -1 }) .select("containers containerSummary createdAt") .lean(); + +export const getDockerContainerStats = async ( + monitorId: string, + containerName: string, + dates: DateRange, + dateString: string +): Promise => + CheckModel.aggregate([ + { + $match: { + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + "metadata.type": "docker", + createdAt: { $gte: dates.start, $lte: dates.end }, + "containers.name": containerName, + }, + }, + { $unwind: "$containers" }, + { $match: { "containers.name": containerName } }, + { + $group: { + _id: { $dateToString: { format: dateString, date: "$createdAt" } }, + avgCpuPct: { $avg: "$containers.cpuPct" }, + avgMemoryUsedBytes: { $avg: "$containers.memoryUsedBytes" }, + avgMemoryPct: { $avg: "$containers.memoryPct" }, + minRestartCount: { $min: "$containers.restartCount" }, + maxRestartCount: { $max: "$containers.restartCount" }, + }, + }, + { $sort: { _id: 1 } }, + ]); + +export const getDockerContainerLatestCheck = async (monitorId: string, containerName: string) => + CheckModel.findOne({ + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + "metadata.type": "docker", + "containers.name": containerName, + }) + .sort({ createdAt: -1 }) + .select("containers createdAt") + .lean(); diff --git a/server/src/domain/checks/check.repository.interface.ts b/server/src/domain/checks/check.repository.interface.ts index 298981216..c48c126d1 100644 --- a/server/src/domain/checks/check.repository.interface.ts +++ b/server/src/domain/checks/check.repository.interface.ts @@ -4,6 +4,7 @@ import type { ChecksSummary, DailyCheckBucket, DockerChecksResult, + DockerContainerStats, HardwareChecksResult, PageSpeedChecksResult, UptimeChecksResult, @@ -40,6 +41,7 @@ export interface IChecksRepository { dateRange: DateRange, options?: { type?: MonitorType } ): Promise; + findDockerContainerChecks(monitorId: string, containerName: string, dateRange: DateRange): Promise>; findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise; findUnevaluatedByMonitorId(monitorId: string, since: number): Promise; getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string): Promise; diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index b779a2295..23106c0b7 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -10,6 +10,7 @@ import type { CheckMemoryInfo, CheckMetadata, CheckNetworkInterfaceInfo, + DockerContainerStats, GotTimings, HardwareCheckStats, } from "@/domain/checks/check.type.js"; @@ -24,7 +25,14 @@ 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"; +import { + getDockerContainerLatestCheck, + getDockerContainerStats, + getDockerLatestCheck, + getDockerStats, + getDockerTotalChecks, + getDockerUpChecks, +} from "@/domain/checks/check.docker.aggregation.js"; const SERVICE_NAME = "ChecksRepository"; @@ -311,6 +319,30 @@ class MongoChecksRepository implements IChecksRepository { return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorObjectId, start, end, dateString); }; + findDockerContainerChecks = async ( + monitorId: string, + containerName: string, + dateRange: DateRange + ): Promise> => { + const dates = { start: getDateForRange(dateRange), end: new Date() }; + const dateString = getDateFormat(dateRange); + const [aggregate, latestDoc] = await Promise.all([ + getDockerContainerStats(monitorId, containerName, dates, dateString), + getDockerContainerLatestCheck(monitorId, containerName), + ]); + const latestContainer = latestDoc?.containers?.find((c) => c.name === containerName); + return { + aggregate, + latest: + latestDoc && latestContainer + ? { + container: latestContainer, + checkedAt: toDateString(latestDoc.createdAt), + } + : null, + }; + }; + findSummaryByTeamId = async (teamId: string, dateRange: DateRange) => { const baseMatch = { "metadata.teamId": new mongoose.Types.ObjectId(teamId), diff --git a/server/src/domain/checks/check.type.ts b/server/src/domain/checks/check.type.ts index 00ab3641d..feeb4439a 100644 --- a/server/src/domain/checks/check.type.ts +++ b/server/src/domain/checks/check.type.ts @@ -281,3 +281,21 @@ export interface DockerStats { export interface DockerChecksResult extends DockerStats { monitorType: "docker"; } + +export type DockerContainerStatsBucket = { + _id: string; + avgCpuPct: number | null; + avgMemoryUsedBytes: number | null; + avgMemoryPct: number | null; + minRestartCount: number | null; + maxRestartCount: number | null; +}; + +export interface DockerContainerStats { + aggregate: DockerContainerStatsBucket[]; + restartsInRange: number; + latest: { + container: DockerContainerInfo; + checkedAt: string; + } | null; +} diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index 14b488af2..b4a7131df 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -8,9 +8,16 @@ import type { GamesMap, GroupedGeoCheckResult, DockerDetailsResult, + DockerContainerDetailsResult, } from "@/domain/monitors/monitor.type.js"; import { supportsGeoCheck, supportsUptimeDetails } from "@/domain/monitors/monitor.type.js"; -import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult, DockerChecksResult } from "@/domain/checks/check.type.js"; +import type { + UptimeChecksResult, + HardwareChecksResult, + PageSpeedChecksResult, + DockerChecksResult, + DockerContainerStatsBucket, +} 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"; @@ -31,6 +38,24 @@ const isUptimeChecksResult = ( result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult | DockerChecksResult ): result is UptimeChecksResult => supportsUptimeDetails(result.monitorType); +// Docker's RestartCount is cumulative and resets to 0 when a container is recreated, so restarts in the +// date range are recovered from bucket min/max deltas; a drop between buckets means a recreate. +const computeRestartsInRange = (aggregate: DockerContainerStatsBucket[]): number => { + let restarts = 0; + let prevMax: number | null = null; + for (const bucket of aggregate) { + const { minRestartCount: min, maxRestartCount: max } = bucket; + if (min === null || max === null) continue; + restarts += Math.max(0, max - min); + if (prevMax !== null) { + const delta = min - prevMax; + restarts += delta >= 0 ? delta : min + 1; + } + prevMax = max; + } + return restarts; +}; + export interface IMonitorService { // create createMonitor(teamId: string, userId: string, body: Partial): Promise; @@ -42,6 +67,12 @@ export interface IMonitorService { getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; getDockerDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; + getDockerContainerByName(args: { + teamId: string; + monitorId: string; + containerName: string; + dateRange: DateRange; + }): Promise; getGeoChecksByMonitorId(args: { teamId: string; monitorId: string; @@ -323,6 +354,37 @@ export class MonitorService implements IMonitorService { }; }; + getDockerContainerByName = async ({ + teamId, + monitorId, + containerName, + dateRange, + }: { + teamId: string; + monitorId: string; + containerName: string; + dateRange: DateRange; + }): Promise => { + const monitor = await this.monitorsRepository.findById(monitorId, teamId); + if (!monitor) { + throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 }); + } + if (monitor.type !== "docker") { + throw new AppError({ message: `${monitor.type} monitors are not supported for docker container details`, status: 400 }); + } + + const { aggregate, latest } = await this.checksRepository.findDockerContainerChecks(monitor.id, containerName, dateRange); + + return { + monitor, + stats: { + aggregate, + latest, + restartsInRange: computeRestartsInRange(aggregate), + }, + }; + }; + getGeoChecksByMonitorId = async ({ teamId, monitorId, diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index e33f89c5c..df9581131 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -1,4 +1,4 @@ -import type { CheckSnapshot, DockerStats } from "@/domain/checks/check.type.js"; +import type { CheckSnapshot, DockerContainerStats, 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"; @@ -160,6 +160,11 @@ export interface DockerDetailsResult { monitorStats: MonitorStats | null; } +export interface DockerContainerDetailsResult { + monitor: Monitor; + stats: DockerContainerStats; +} + export interface PageSpeedDetailsResult { monitorData: { monitor: Monitor;