new container endpoint,service, repo methods

This commit is contained in:
Alex Holliday committed 2026-09-01 10:05:34 -07:00
1 parent d347e29a28
commit 8ccc7cbe9a
9 files changed
+195 -4

No files matched your search

@@ -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);
+1
View File
@@ -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);
@@ -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
@@ -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<DockerContainerStatsBucket[]> =>
CheckModel.aggregate<DockerContainerStatsBucket>([
{
$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();
@@ -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<UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult | DockerChecksResult>;
findDockerContainerChecks(monitorId: string, containerName: string, dateRange: DateRange): Promise<Omit<DockerContainerStats, "restartsInRange">>;
findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise<ChecksSummary>;
findUnevaluatedByMonitorId(monitorId: string, since: number): Promise<Check[]>;
getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string): Promise<DailyCheckBucket[]>;
@@ -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<Omit<DockerContainerStats, "restartsInRange">> => {
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),
+18
View File
@@ -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;
}
+63 -1
View File
@@ -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<Monitor>): Promise<void>;
@@ -42,6 +67,12 @@ export interface IMonitorService {
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>;
getDockerContainerByName(args: {
teamId: string;
monitorId: string;
containerName: string;
dateRange: DateRange;
}): Promise<DockerContainerDetailsResult>;
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<DockerContainerDetailsResult> => {
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,
+6 -1
View File
@@ -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;