From 8ccc7cbe9a94f54ec0392b27ef240cf1cddbc82d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 1 Sep 2026 10:05:34 -0700 Subject: [PATCH 1/6] new container endpoint,service, repo methods --- .../src/api/controllers/monitorController.ts | 25 ++++++++ server/src/api/routes/monitorRoutes.ts | 1 + .../src/api/validation/monitorValidation.ts | 6 ++ .../domain/checks/check.docker.aggregation.ts | 42 +++++++++++- .../checks/check.repository.interface.ts | 2 + .../domain/checks/check.repository.mongo.ts | 34 +++++++++- server/src/domain/checks/check.type.ts | 18 ++++++ server/src/domain/monitors/monitor.service.ts | 64 ++++++++++++++++++- server/src/domain/monitors/monitor.type.ts | 7 +- 9 files changed, 195 insertions(+), 4 deletions(-) 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; From f85ed902e40ab199555bd52ff9f912155a721e50 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 1 Sep 2026 10:09:37 -0700 Subject: [PATCH 2/6] openapi docs, tests --- server/openapi.json | 419 ++++++++++++++++++ server/openapi/routes/monitor.ts | 13 + .../src/api/validation/monitorValidation.ts | 26 ++ .../test/unit/services/monitorService.test.ts | 108 +++++ 4 files changed, 566 insertions(+) diff --git a/server/openapi.json b/server/openapi.json index de869c314..cd6a3466d 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -7467,6 +7467,425 @@ } } }, + "/monitors/docker/details/{monitorId}/containers/{containerName}": { + "get": { + "tags": ["monitors"], + "summary": "Get details for one container on a Docker host monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "containerName", + "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": { + "aggregate": { + "type": "array", + "items": { + "type": "object", + "properties": { + "_id": { + "type": "string" + }, + "avgCpuPct": { + "type": "number", + "nullable": true + }, + "avgMemoryUsedBytes": { + "type": "number", + "nullable": true + }, + "avgMemoryPct": { + "type": "number", + "nullable": true + }, + "minRestartCount": { + "type": "number", + "nullable": true + }, + "maxRestartCount": { + "type": "number", + "nullable": true + } + }, + "required": ["_id", "avgCpuPct", "avgMemoryUsedBytes", "avgMemoryPct", "minRestartCount", "maxRestartCount"] + } + }, + "restartsInRange": { + "type": "number" + }, + "latest": { + "type": "object", + "nullable": true, + "properties": { + "container": { + "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"] + }, + "checkedAt": { + "type": "string" + } + }, + "required": ["container", "checkedAt"] + } + }, + "required": ["aggregate", "restartsInRange", "latest"] + } + }, + "required": ["monitor", "stats"] + } + }, + "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"], diff --git a/server/openapi/routes/monitor.ts b/server/openapi/routes/monitor.ts index fb388c909..f7d92c94e 100644 --- a/server/openapi/routes/monitor.ts +++ b/server/openapi/routes/monitor.ts @@ -18,7 +18,10 @@ import { getHardwareDetailsByIdQueryValidation, getDockerDetailsByIdParamValidation, getDockerDetailsByIdQueryValidation, + getDockerContainerNameParamValidation, + getDockerContainerByNameQueryValidation, dockerDetailsResponseSchema, + dockerContainerDetailsResponseSchema, monitorResponseSchema, uptimeDetailsResponseSchema, } from "@/api/validation/monitorValidation.js"; @@ -118,6 +121,16 @@ registry.registerPath({ responses: { "200": okJson(dockerDetailsResponseSchema), ...standardErrors }, }); +registry.registerPath({ + method: "get", + path: "/monitors/docker/details/{monitorId}/containers/{containerName}", + tags, + summary: "Get details for one container on a Docker host monitor", + security: bearer, + request: { params: getDockerContainerNameParamValidation, query: getDockerContainerByNameQueryValidation }, + responses: { "200": okJson(dockerContainerDetailsResponseSchema), ...standardErrors }, +}); + registry.registerPath({ method: "get", path: "/monitors/pagespeed/details/{monitorId}", diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index aabf6a957..3b94f6989 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -484,3 +484,29 @@ export const dockerDetailsResponseSchema = z.object({ }), monitorStats: monitorStatsResponseSchema.nullable(), }); + +// Keep aligned with DockerContainerStatsBucket in domain/checks/check.type.ts. +export const dockerContainerStatsBucketResponseSchema = z.object({ + _id: z.string(), + avgCpuPct: z.number().nullable(), + avgMemoryUsedBytes: z.number().nullable(), + avgMemoryPct: z.number().nullable(), + minRestartCount: z.number().nullable(), + maxRestartCount: z.number().nullable(), +}); + +// Response of GET /monitors/docker/details/{monitorId}/containers/{containerName}. Keep +// aligned with DockerContainerDetailsResult in domain/monitors/monitor.type.ts. +export const dockerContainerDetailsResponseSchema = z.object({ + monitor: monitorResponseSchema.omit({ _id: true }).extend({ id: z.string() }), + stats: z.object({ + aggregate: z.array(dockerContainerStatsBucketResponseSchema), + restartsInRange: z.number(), + latest: z + .object({ + container: dockerContainerResponseSchema, + checkedAt: z.string(), + }) + .nullable(), + }), +}); diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index c725fcd10..79753e41c 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -29,6 +29,7 @@ const createMonitorsRepositoryMock = () => const createChecksRepositoryMock = () => ({ findByDateRangeAndMonitorId: jest.fn(), + findDockerContainerChecks: jest.fn(), deleteByMonitorId: jest.fn(), }) as unknown as IChecksRepository; @@ -486,6 +487,113 @@ describe("MonitorService", () => { }); }); + describe("getDockerContainerByName", () => { + const makeBucket = (overrides: Partial> = {}) => ({ + _id: "2024-01-01T00:00:00Z", + avgCpuPct: 12.5, + avgMemoryUsedBytes: 1024, + avgMemoryPct: 50, + minRestartCount: 0, + maxRestartCount: 0, + ...overrides, + }); + + it("returns container details for a docker monitor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitor = makeMonitor({ type: "docker" }); + const container = { id: "abc", name: "checkmate-mongo", image: "mongo:8.0", state: "running", status: "Up 3 hours", health: "healthy" }; + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findDockerContainerChecks as jest.Mock).mockResolvedValue({ + aggregate: [makeBucket()], + latest: { container, checkedAt: "2024-01-01T00:00:00Z" }, + }); + + const { service } = createService({ monitorsRepository, checksRepository }); + const result = await service.getDockerContainerByName({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + containerName: "checkmate-mongo", + dateRange: "recent", + }); + + expect(checksRepository.findDockerContainerChecks).toHaveBeenCalledWith(MONITOR_ID, "checkmate-mongo", "recent"); + expect(result.monitor).toMatchObject({ id: MONITOR_ID }); + expect(result.stats.latest?.container).toMatchObject({ name: "checkmate-mongo" }); + expect(result.stats.restartsInRange).toBe(0); + }); + + it("sums restarts within and between buckets", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + (checksRepository.findDockerContainerChecks as jest.Mock).mockResolvedValue({ + aggregate: [ + makeBucket({ _id: "01", minRestartCount: 0, maxRestartCount: 2 }), + makeBucket({ _id: "02", minRestartCount: 3, maxRestartCount: 3 }), + makeBucket({ _id: "03", minRestartCount: null, maxRestartCount: null }), + makeBucket({ _id: "04", minRestartCount: 3, maxRestartCount: 4 }), + ], + latest: null, + }); + + const { service } = createService({ monitorsRepository, checksRepository }); + const result = await service.getDockerContainerByName({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + containerName: "checkmate-mongo", + dateRange: "day", + }); + + // 2 within bucket 01, +1 between 01 and 02, +1 within 04 (null bucket skipped) + expect(result.stats.restartsInRange).toBe(4); + }); + + it("counts a restart-count drop as a recreate", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + (checksRepository.findDockerContainerChecks as jest.Mock).mockResolvedValue({ + aggregate: [ + makeBucket({ _id: "01", minRestartCount: 5, maxRestartCount: 5 }), + makeBucket({ _id: "02", minRestartCount: 1, maxRestartCount: 1 }), + ], + latest: null, + }); + + const { service } = createService({ monitorsRepository, checksRepository }); + const result = await service.getDockerContainerByName({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + containerName: "checkmate-mongo", + dateRange: "day", + }); + + // counter dropped 5 -> 1: the recreate itself plus the 1 restart the new counter shows + expect(result.stats.restartsInRange).toBe(2); + }); + + 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.getDockerContainerByName({ teamId: TEAM_ID, monitorId: "missing", containerName: "checkmate-mongo", dateRange: "recent" }) + ).rejects.toThrow("Monitor with ID missing not found."); + }); + + it("throws 400 when monitor type is not docker", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "http" })); + const { service } = createService({ monitorsRepository }); + + await expect( + service.getDockerContainerByName({ teamId: TEAM_ID, monitorId: MONITOR_ID, containerName: "checkmate-mongo", dateRange: "recent" }) + ).rejects.toThrow("monitors are not supported for docker container details"); + }); + }); + describe("getPageSpeedDetailsById", () => { it("returns pagespeed details for a pagespeed monitor", async () => { const monitorsRepository = createMonitorsRepositoryMock(); From 56153b38a50a6fc733fe3aebdda963ba75762250 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 1 Sep 2026 11:23:03 -0700 Subject: [PATCH 3/6] containers detail charts --- .../Components/design-elements/BaseChart.tsx | 28 ++++- .../Components/monitors/charts/ChartUtils.ts | 34 ------ .../Components/monitors/charts/ChartUtils.tsx | 113 ++++++++++++++++++ .../charts/HistogramDockerContainer.tsx | 70 +++++++++++ .../charts/HistogramInfrastructure.tsx | 80 +------------ .../Docker/Details/Components/Charts.tsx | 0 .../Docker/Details/Components/StatBoxes.tsx | 54 +++++++++ client/src/Pages/Docker/Details/TabLogs.tsx | 3 + .../src/Pages/Docker/Details/TabOverview.tsx | 62 ++++++++++ client/src/Pages/Docker/Details/index.tsx | 74 +++++++++++- .../Components/DockerContainersTable.tsx | 13 +- client/src/Pages/Docker/HostDetails/index.tsx | 5 +- .../Details/Components/Charts.tsx | 4 +- client/src/Routes/index.tsx | 2 +- client/src/Types/Monitor.ts | 25 +++- client/src/locales/en.json | 24 +++- 16 files changed, 451 insertions(+), 140 deletions(-) delete mode 100644 client/src/Components/monitors/charts/ChartUtils.ts create mode 100644 client/src/Components/monitors/charts/ChartUtils.tsx create mode 100644 client/src/Components/monitors/charts/HistogramDockerContainer.tsx create mode 100644 client/src/Pages/Docker/Details/Components/Charts.tsx create mode 100644 client/src/Pages/Docker/Details/Components/StatBoxes.tsx create mode 100644 client/src/Pages/Docker/Details/TabLogs.tsx create mode 100644 client/src/Pages/Docker/Details/TabOverview.tsx diff --git a/client/src/Components/design-elements/BaseChart.tsx b/client/src/Components/design-elements/BaseChart.tsx index 319a4ad51..e5233eb0f 100644 --- a/client/src/Components/design-elements/BaseChart.tsx +++ b/client/src/Components/design-elements/BaseChart.tsx @@ -10,6 +10,7 @@ import { useTheme } from "@mui/material/styles"; type BaseChartProps = React.PropsWithChildren<{ icon?: React.ReactNode; title: string; + rightTitle?: string; width?: number | string; maxWidth?: number | string; flexBasis?: number | string; @@ -20,6 +21,7 @@ type BaseChartProps = React.PropsWithChildren<{ export const BaseChart = ({ children, title, + rightTitle, width = "100%", maxWidth = "100%", flexBasis = "0%", @@ -47,13 +49,27 @@ export const BaseChart = ({ gap={theme.spacing(LAYOUT.MD)} flex={1} > - - {title} - + + {title} + + {rightTitle && ( + + {rightTitle} + + )} + {children} diff --git a/client/src/Components/monitors/charts/ChartUtils.ts b/client/src/Components/monitors/charts/ChartUtils.ts deleted file mode 100644 index 0516eaac3..000000000 --- a/client/src/Components/monitors/charts/ChartUtils.ts +++ /dev/null @@ -1,34 +0,0 @@ -export const computeYAxisCap = (values: number[]): number | undefined => { - const sorted = values.slice().sort((a, b) => a - b); - if (sorted.length < 2) return undefined; - const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))]; - return p95 > 0 ? Math.ceil(p95 * 2) : undefined; -}; - -export const PHASE_KEYS = [ - "avgDns", - "avgTcp", - "avgTls", - "avgRequest", - "avgFirstByte", - "avgDownload", -] as const; - -export const PHASE_COLOR_KEYS = { - avgDns: "dns", - avgTcp: "tcp", - avgTls: "tls", - avgRequest: "request", - avgFirstByte: "firstByte", - avgDownload: "download", -} as const; - -// i18n keys, resolved with t() at render -export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = { - avgDns: "common.charts.phases.dns", - avgTcp: "common.charts.phases.tcp", - avgTls: "common.charts.phases.tls", - avgRequest: "common.charts.phases.request", - avgFirstByte: "common.charts.phases.firstByte", - avgDownload: "common.charts.phases.download", -}; diff --git a/client/src/Components/monitors/charts/ChartUtils.tsx b/client/src/Components/monitors/charts/ChartUtils.tsx new file mode 100644 index 000000000..c90fdb6d5 --- /dev/null +++ b/client/src/Components/monitors/charts/ChartUtils.tsx @@ -0,0 +1,113 @@ +export const computeYAxisCap = (values: number[]): number | undefined => { + const sorted = values.slice().sort((a, b) => a - b); + if (sorted.length < 2) return undefined; + const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))]; + return p95 > 0 ? Math.ceil(p95 * 2) : undefined; +}; + +export const PHASE_KEYS = [ + "avgDns", + "avgTcp", + "avgTls", + "avgRequest", + "avgFirstByte", + "avgDownload", +] as const; + +export const PHASE_COLOR_KEYS = { + avgDns: "dns", + avgTcp: "tcp", + avgTls: "tls", + avgRequest: "request", + avgFirstByte: "firstByte", + avgDownload: "download", +} as const; + +// i18n keys, resolved with t() at render +export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = { + avgDns: "common.charts.phases.dns", + avgTcp: "common.charts.phases.tcp", + avgTls: "common.charts.phases.tls", + avgRequest: "common.charts.phases.request", + avgFirstByte: "common.charts.phases.firstByte", + avgDownload: "common.charts.phases.download", +}; + +export const AREA_COLORS = [ + // Blues + "#3182bd", // Deep blue + "#6baed6", // Medium blue + "#9ecae1", // Light blue + + // Greens + "#74c476", // Soft green + "#a1d99b", // Light green + "#c7e9c0", // Pale green + + // Oranges + "#fdae6b", // Warm orange + "#fdd0a2", // Light orange + "#feedde", // Pale orange + + // Purples + "#9467bd", // Lavender + "#a55194", // Deep magenta + "#c994c7", // Soft magenta + + // Reds + "#ff9896", // Soft red + "#de2d26", // Deep red + "#fc9272", // Medium red + + // Cyans/Teals + "#17becf", // Cyan + "#7fcdbb", // Teal + "#a1dab4", // Light teal + + // Yellows + "#fec44f", // Mustard + "#fee391", // Light yellow + "#ffffd4", // Pale yellow + + // Additional colors + "#e377c2", // Soft pink + "#bcbd22", // Olive + "#2ca02c", // Vibrant green +]; + +export const createGradient = ({ + id, + startColor, + endColor, + startOpacity = 0.8, + endOpacity = 0, + direction = "vertical", +}: { + id: string; + startColor: string; + endColor: string; + startOpacity?: number; + endOpacity?: number; + direction?: "vertical" | "horizontal"; +}) => ( + + + + + + +); diff --git a/client/src/Components/monitors/charts/HistogramDockerContainer.tsx b/client/src/Components/monitors/charts/HistogramDockerContainer.tsx new file mode 100644 index 000000000..b7a420fc9 --- /dev/null +++ b/client/src/Components/monitors/charts/HistogramDockerContainer.tsx @@ -0,0 +1,70 @@ +import { BaseChart } from "@/Components/design-elements"; +import { Area, AreaChart, CartesianGrid, ResponsiveContainer } from "recharts"; + +// Types +import type { DockerContainerStats } from "@/Types/Monitor"; + +// Hooks +import { useTheme } from "@mui/material"; +import { Fragment, useId } from "react"; + +// Utils +import { createGradient } from "@/Components/monitors/charts/ChartUtils"; +interface HistogramDockerContainerProps { + title: string; + rightTitle: string; + stats: DockerContainerStats; + dataKey: string; + strokeColor: string; + gradientStartColor: string; +} + +export const HistogramDockerContainer = ({ + title, + rightTitle, + stats, + dataKey, + strokeColor, + gradientStartColor, +}: HistogramDockerContainerProps) => { + const uniqueId = useId(); + const theme = useTheme(); + const gradientId = `gradient-${uniqueId}`; + + return ( + + + + + + {createGradient({ + id: gradientId, + startColor: gradientStartColor, + endColor: "transparent", + direction: "vertical", + })} + + + + + + ); +}; diff --git a/client/src/Components/monitors/charts/HistogramInfrastructure.tsx b/client/src/Components/monitors/charts/HistogramInfrastructure.tsx index 8163898aa..bd3151364 100644 --- a/client/src/Components/monitors/charts/HistogramInfrastructure.tsx +++ b/client/src/Components/monitors/charts/HistogramInfrastructure.tsx @@ -12,85 +12,7 @@ import { XTick } from "@/Components/monitors/charts/XTick"; import { useTheme } from "@mui/material/styles"; import type { HardwareCheckStats } from "@/Types/Monitor"; - -const AREA_COLORS = [ - // Blues - "#3182bd", // Deep blue - "#6baed6", // Medium blue - "#9ecae1", // Light blue - - // Greens - "#74c476", // Soft green - "#a1d99b", // Light green - "#c7e9c0", // Pale green - - // Oranges - "#fdae6b", // Warm orange - "#fdd0a2", // Light orange - "#feedde", // Pale orange - - // Purples - "#9467bd", // Lavender - "#a55194", // Deep magenta - "#c994c7", // Soft magenta - - // Reds - "#ff9896", // Soft red - "#de2d26", // Deep red - "#fc9272", // Medium red - - // Cyans/Teals - "#17becf", // Cyan - "#7fcdbb", // Teal - "#a1dab4", // Light teal - - // Yellows - "#fec44f", // Mustard - "#fee391", // Light yellow - "#ffffd4", // Pale yellow - - // Additional colors - "#e377c2", // Soft pink - "#bcbd22", // Olive - "#2ca02c", // Vibrant green -]; - -const createGradient = ({ - id, - startColor, - endColor, - startOpacity = 0.8, - endOpacity = 0, - direction = "vertical", -}: { - id: string; - startColor: string; - endColor: string; - startOpacity?: number; - endOpacity?: number; - direction?: "vertical" | "horizontal"; -}) => ( - - - - - - -); +import { AREA_COLORS, createGradient } from "@/Components/monitors/charts/ChartUtils"; export const HistogramInfrastructure = ({ dateRange, diff --git a/client/src/Pages/Docker/Details/Components/Charts.tsx b/client/src/Pages/Docker/Details/Components/Charts.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/Pages/Docker/Details/Components/StatBoxes.tsx b/client/src/Pages/Docker/Details/Components/StatBoxes.tsx new file mode 100644 index 000000000..79bf89e16 --- /dev/null +++ b/client/src/Pages/Docker/Details/Components/StatBoxes.tsx @@ -0,0 +1,54 @@ +import Stack from "@mui/material/Stack"; +import { StatBox } from "@/Components/design-elements"; + +// Types +import type { Monitor } from "@/Types/Monitor"; + +// Hooks +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import type { DockerContainerInfo } from "@/Types/Check"; + +// Utils +import { formatDuration } from "@/Utils/TimeUtils"; + +export const DockerContainerStatusBoxes = ({ + monitor, + container, +}: { + monitor: Monitor | undefined; + container: DockerContainerInfo | undefined; +}) => { + const { t } = useTranslation(); + const theme = useTheme(); + + if (!container) return null; + + const { state, restartCount, health } = container; + const startedAt = container.startedAt ?? "0"; + const runningFor = Date.now() - new Date(startedAt).getTime(); + return ( + + + + + + + ); +}; diff --git a/client/src/Pages/Docker/Details/TabLogs.tsx b/client/src/Pages/Docker/Details/TabLogs.tsx new file mode 100644 index 000000000..9dcea4842 --- /dev/null +++ b/client/src/Pages/Docker/Details/TabLogs.tsx @@ -0,0 +1,3 @@ +export const TabLogs = () => { + return "This is the logs tab"; +}; diff --git a/client/src/Pages/Docker/Details/TabOverview.tsx b/client/src/Pages/Docker/Details/TabOverview.tsx new file mode 100644 index 000000000..ce746cc19 --- /dev/null +++ b/client/src/Pages/Docker/Details/TabOverview.tsx @@ -0,0 +1,62 @@ +// Components +import Grid from "@mui/material/Grid"; +import { HistogramDockerContainer } from "@/Components/monitors/charts/HistogramDockerContainer"; + +// Hooks +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import useMediaQuery from "@mui/material/useMediaQuery"; + +// Types +import type { Theme } from "@mui/material"; +import type { TFunction } from "i18next"; +import type { DockerContainerStats } from "@/Types/Monitor"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import { formatPercentage } from "@/Utils/FormatUtils"; +import prettyBytes from "pretty-bytes"; + +const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats) => [ + { + title: t("common.charts.labels.cpuUsage"), + key: "avgCpuPct", + color: theme.palette.success.main, + rightTitle: formatPercentage(stats?.latest?.container.cpuPct || 0), + }, + { + title: t("common.charts.labels.memoryUsage"), + key: "avgMemoryUsedBytes", + color: theme.palette.primary.main, + rightTitle: prettyBytes(stats?.latest?.container.memoryUsedBytes || 0), + }, +]; + +export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => { + const theme = useTheme(); + const { t } = useTranslation(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + + return ( + + {getChartConfigs(theme, t, stats).map((config) => { + return ( + + + + ); + })} + + ); +}; diff --git a/client/src/Pages/Docker/Details/index.tsx b/client/src/Pages/Docker/Details/index.tsx index 5937e113e..33ef7da9e 100644 --- a/client/src/Pages/Docker/Details/index.tsx +++ b/client/src/Pages/Docker/Details/index.tsx @@ -1,5 +1,75 @@ -const DockerDetailsPage = () => { - return null; +// Components +import { BasePage, Tabs, Tab, DockerStateLabel } from "@/Components/design-elements"; +import { TabOverview } from "@/Pages/Docker/Details/TabOverview"; +import { TabLogs } from "@/Pages/Docker/Details/TabLogs"; + +// Hooks +import { useParams } from "react-router-dom"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useGet } from "@/Hooks/UseApi"; +import type { DockerContainerResponse } from "@/Types/Monitor"; +import { DockerContainerStatusBoxes } from "@/Pages/Docker/Details/Components/StatBoxes"; +interface DockerContainerDetailsProps { + open?: "overview" | "logs"; +} + +const TAB_MAP = { + overview: 0, + logs: 1, +} as const; + +const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) => { + const [activeTab, setActiveTab] = useState(TAB_MAP[open]); + const { monitorId, containerName } = useParams<{ + monitorId: string; + containerName: string; + }>(); + const { t } = useTranslation(); + + const monitorDetailsUrl = monitorId + ? `/monitors/docker/details/${monitorId}/containers/${containerName}` + : null; + + const { data: containerData, refetch: refetchContainer } = + useGet( + monitorDetailsUrl, + {}, + { refreshInterval: 10000, keepPreviousData: true } + ); + + const monitor = containerData?.monitor; + if (!monitor) return null; + + const stats = containerData.stats; + + const container = stats?.latest?.container; + if (!container) return null; + + const { state, name, image } = container; + + return ( + + + + setActiveTab(newValue)} + > + + + + {activeTab === 0 && } + {activeTab === 1 && } + + ); }; export default DockerDetailsPage; diff --git a/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx b/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx index bf5bdc216..72d828f45 100644 --- a/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx +++ b/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx @@ -15,11 +15,15 @@ import type { DockerContainerInfo } from "@/Types/Check"; import { formatPercentage } from "@/Utils/FormatUtils"; interface DockerContainersTableProps { + monitorId: string | undefined; containers: DockerContainerInfo[]; onRowClick?: (container: DockerContainerInfo) => void; } -export const DockerContainersTable = ({ containers }: DockerContainersTableProps) => { +export const DockerContainersTable = ({ + monitorId, + containers, +}: DockerContainersTableProps) => { const { t } = useTranslation(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); @@ -64,7 +68,7 @@ export const DockerContainersTable = ({ containers }: DockerContainersTableProps }, { id: "health", - content: t("pages.docker.host.table.headers.health"), + content: t("common.labels.health"), render: (row) => { return {row.health}; }, @@ -85,7 +89,7 @@ export const DockerContainersTable = ({ containers }: DockerContainersTableProps }, { id: "restarts", - content: t("pages.docker.host.table.headers.restarts"), + content: t("common.labels.restarts"), render: (row) => { return {row.restartCount}; }, @@ -100,14 +104,13 @@ export const DockerContainersTable = ({ containers }: DockerContainersTableProps headers = headers.filter((h) => h.id !== "histogram"); } - console.log(JSON.stringify(containers, null, 2)); return ( { - navigate(`/docker/container/${row.id}`); + navigate(`/docker/host/${monitorId}/container/${row.name}`); }} // getRowSx={(row) => ({ // backgroundColor: isRowSelected(row.id) diff --git a/client/src/Pages/Docker/HostDetails/index.tsx b/client/src/Pages/Docker/HostDetails/index.tsx index 4cd2d3903..7c2d3c80c 100644 --- a/client/src/Pages/Docker/HostDetails/index.tsx +++ b/client/src/Pages/Docker/HostDetails/index.tsx @@ -44,7 +44,10 @@ const DockerHostDetailsPage = () => { monitorStats={monitorStats} /> - + ); }; diff --git a/client/src/Pages/Infrastructure/Details/Components/Charts.tsx b/client/src/Pages/Infrastructure/Details/Components/Charts.tsx index 4309feca7..4ef5fa145 100644 --- a/client/src/Pages/Infrastructure/Details/Components/Charts.tsx +++ b/client/src/Pages/Infrastructure/Details/Components/Charts.tsx @@ -12,7 +12,7 @@ const formatTemp = (value: number) => `${value.toFixed(0)}°C`; const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => { return [ { - title: t("pages.infrastructure.charts.labels.memory"), + title: t("common.charts.labels.memoryUsage"), type: "memory", dataKeys: ["avgMemoryUsage"], strokeColor: theme.palette.primary.main, @@ -22,7 +22,7 @@ const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => { idx: null, }, { - title: t("pages.infrastructure.charts.labels.cpu"), + title: t("common.charts.labels.cpuUsage"), type: "cpu", dataKeys: ["avgCpuUsage"], strokeColor: theme.palette.success.main, diff --git a/client/src/Routes/index.tsx b/client/src/Routes/index.tsx index e7735fcd0..a3fccf378 100644 --- a/client/src/Routes/index.tsx +++ b/client/src/Routes/index.tsx @@ -157,7 +157,7 @@ const Routes = () => { element={} /> } /> Date: Wed, 2 Sep 2026 09:45:28 -0700 Subject: [PATCH 4/6] add port/mount display --- .../common/controls/HeaderTimeRange.tsx | 7 +- client/src/Pages/Checks/index.tsx | 3 +- .../Components/Charts.tsx | 0 .../Components/StatBoxes.tsx | 0 .../{Details => ContainerDetails}/TabLogs.tsx | 0 .../TabOverview.tsx | 55 ++++++++++- .../{Details => ContainerDetails}/index.tsx | 19 ++-- client/src/Pages/Incidents/index.tsx | 3 +- .../Pages/Infrastructure/Details/index.tsx | 3 +- client/src/Pages/PageSpeed/Details/index.tsx | 3 +- client/src/Pages/Uptime/Details/index.tsx | 3 +- client/src/Routes/index.tsx | 2 +- client/src/Types/Check.ts | 27 ++++- client/src/Types/Query.ts | 2 + client/src/Utils/MonitorUtils.ts | 35 ++++++- server/openapi.json | 98 +++++++++++++++++++ .../src/api/validation/monitorValidation.ts | 21 +++- server/src/domain/checks/check.model.ts | 34 ++++++- server/src/service/network/DockerProvider.ts | 46 +++++++++ server/src/types/network.ts | 21 ++++ .../providers/network/dockerProvider.test.ts | 83 ++++++++++++++++ 21 files changed, 441 insertions(+), 24 deletions(-) rename client/src/Pages/Docker/{Details => ContainerDetails}/Components/Charts.tsx (100%) rename client/src/Pages/Docker/{Details => ContainerDetails}/Components/StatBoxes.tsx (100%) rename client/src/Pages/Docker/{Details => ContainerDetails}/TabLogs.tsx (100%) rename client/src/Pages/Docker/{Details => ContainerDetails}/TabOverview.tsx (52%) rename client/src/Pages/Docker/{Details => ContainerDetails}/index.tsx (75%) create mode 100644 client/src/Types/Query.ts diff --git a/client/src/Components/common/controls/HeaderTimeRange.tsx b/client/src/Components/common/controls/HeaderTimeRange.tsx index d7c7102bc..254de71b2 100644 --- a/client/src/Components/common/controls/HeaderTimeRange.tsx +++ b/client/src/Components/common/controls/HeaderTimeRange.tsx @@ -6,12 +6,13 @@ import { useTheme } from "@mui/material/styles"; import CircularProgress from "@mui/material/CircularProgress"; import { useTranslation } from "react-i18next"; import { useMediaQuery } from "@mui/material"; +import type { DateRange } from "@/Types/Query"; interface MonitorTimeFrameHeaderProps { isLoading?: boolean; hasDateRange?: boolean; - dateRange: string; - setDateRange: (dateRange: string) => void; + dateRange: DateRange; + setDateRange: (dateRange: DateRange) => void; } export const HeaderTimeRange = ({ @@ -25,7 +26,7 @@ export const HeaderTimeRange = ({ const isSmallScreen = useMediaQuery(theme.breakpoints.down("md")); const handleChange = ( _event: React.MouseEvent, - newValue: string | null + newValue: DateRange | null ) => { if (newValue !== null) { setDateRange(newValue); diff --git a/client/src/Pages/Checks/index.tsx b/client/src/Pages/Checks/index.tsx index 93364af44..453660413 100644 --- a/client/src/Pages/Checks/index.tsx +++ b/client/src/Pages/Checks/index.tsx @@ -17,13 +17,14 @@ import { useParams } from "react-router-dom"; import { useGet } from "@/Hooks/UseApi"; import type { Monitor } from "@/Types/Monitor"; import type { ChecksSummary, ChecksResponse } from "@/Types/Check"; +import type { DateRange } from "@/Types/Query"; const Checks = () => { const { t } = useTranslation(); const { monitorId } = useParams<{ monitorId?: string }>(); const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0"); - const [dateRange, setDateRange] = useState("recent"); + const [dateRange, setDateRange] = useState("recent"); const [statusFilter, setStatusFilter] = useState("down"); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(10); diff --git a/client/src/Pages/Docker/Details/Components/Charts.tsx b/client/src/Pages/Docker/ContainerDetails/Components/Charts.tsx similarity index 100% rename from client/src/Pages/Docker/Details/Components/Charts.tsx rename to client/src/Pages/Docker/ContainerDetails/Components/Charts.tsx diff --git a/client/src/Pages/Docker/Details/Components/StatBoxes.tsx b/client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx similarity index 100% rename from client/src/Pages/Docker/Details/Components/StatBoxes.tsx rename to client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx diff --git a/client/src/Pages/Docker/Details/TabLogs.tsx b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx similarity index 100% rename from client/src/Pages/Docker/Details/TabLogs.tsx rename to client/src/Pages/Docker/ContainerDetails/TabLogs.tsx diff --git a/client/src/Pages/Docker/Details/TabOverview.tsx b/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx similarity index 52% rename from client/src/Pages/Docker/Details/TabOverview.tsx rename to client/src/Pages/Docker/ContainerDetails/TabOverview.tsx index ce746cc19..47503bff3 100644 --- a/client/src/Pages/Docker/Details/TabOverview.tsx +++ b/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx @@ -1,9 +1,10 @@ // Components import Grid from "@mui/material/Grid"; +import Stack from "@mui/material/Stack"; import { HistogramDockerContainer } from "@/Components/monitors/charts/HistogramDockerContainer"; // Hooks -import { useTheme } from "@mui/material"; +import { Typography, useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import useMediaQuery from "@mui/material/useMediaQuery"; @@ -11,9 +12,11 @@ import useMediaQuery from "@mui/material/useMediaQuery"; import type { Theme } from "@mui/material"; import type { TFunction } from "i18next"; import type { DockerContainerStats } from "@/Types/Monitor"; -import { LAYOUT } from "@/Utils/Theme/constants"; +import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; import { formatPercentage } from "@/Utils/FormatUtils"; import prettyBytes from "pretty-bytes"; +import { BaseBox } from "@/Components/design-elements"; +import { dedupeDockerPorts, getDockerMountLabel } from "@/Utils/MonitorUtils"; const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats) => [ { @@ -35,6 +38,8 @@ export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => { const { t } = useTranslation(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const { ports, mounts } = stats?.latest?.container ?? {}; + const dedupedPorts = dedupeDockerPorts(ports ?? []); return ( { ); })} + + + + Ports + + + {dedupedPorts?.map((port) => { + return ( + {`${port.hostIp}:${port.publicPort} -> ${port.privatePort}/${port.protocol}`} + ); + })} + + + + + + + Volumes + + + {mounts?.map((mount) => { + return ( + + {`${getDockerMountLabel(mount)} -> ${mount.destination}`} + + ); + })} + + + ); }; diff --git a/client/src/Pages/Docker/Details/index.tsx b/client/src/Pages/Docker/ContainerDetails/index.tsx similarity index 75% rename from client/src/Pages/Docker/Details/index.tsx rename to client/src/Pages/Docker/ContainerDetails/index.tsx index 33ef7da9e..e3462d86e 100644 --- a/client/src/Pages/Docker/Details/index.tsx +++ b/client/src/Pages/Docker/ContainerDetails/index.tsx @@ -1,7 +1,7 @@ // Components import { BasePage, Tabs, Tab, DockerStateLabel } from "@/Components/design-elements"; -import { TabOverview } from "@/Pages/Docker/Details/TabOverview"; -import { TabLogs } from "@/Pages/Docker/Details/TabLogs"; +import { TabOverview } from "@/Pages/Docker/ContainerDetails/TabOverview"; +import { TabLogs } from "@/Pages/Docker/ContainerDetails/TabLogs"; // Hooks import { useParams } from "react-router-dom"; @@ -9,7 +9,9 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useGet } from "@/Hooks/UseApi"; import type { DockerContainerResponse } from "@/Types/Monitor"; -import { DockerContainerStatusBoxes } from "@/Pages/Docker/Details/Components/StatBoxes"; +import { DockerContainerStatusBoxes } from "@/Pages/Docker/ContainerDetails/Components/StatBoxes"; +import type { DateRange } from "@/Types/Query"; +import { HeaderTimeRange } from "@/Components/common"; interface DockerContainerDetailsProps { open?: "overview" | "logs"; } @@ -21,19 +23,20 @@ const TAB_MAP = { const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) => { const [activeTab, setActiveTab] = useState(TAB_MAP[open]); + const [dateRange, setDateRange] = useState("recent"); const { monitorId, containerName } = useParams<{ monitorId: string; containerName: string; }>(); const { t } = useTranslation(); - const monitorDetailsUrl = monitorId - ? `/monitors/docker/details/${monitorId}/containers/${containerName}` + const containerDetailsUrl = monitorId + ? `/monitors/docker/details/${monitorId}/containers/${containerName}?dateRange=${dateRange}` : null; const { data: containerData, refetch: refetchContainer } = useGet( - monitorDetailsUrl, + containerDetailsUrl, {}, { refreshInterval: 10000, keepPreviousData: true } ); @@ -59,6 +62,10 @@ const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) = container={container} monitor={monitor} /> + setActiveTab(newValue)} diff --git a/client/src/Pages/Incidents/index.tsx b/client/src/Pages/Incidents/index.tsx index 63cd18889..b1d18a8a4 100644 --- a/client/src/Pages/Incidents/index.tsx +++ b/client/src/Pages/Incidents/index.tsx @@ -20,6 +20,7 @@ import { useState, useEffect, useMemo } from "react"; import { useParams } from "react-router-dom"; import type { Incident, IncidentsResponse, IncidentSummary } from "@/Types/Incident"; import type { Monitor } from "@/Types/Monitor"; +import type { DateRange } from "@/Types/Query"; import { useTheme } from "@mui/material"; const IncidentsPage = () => { @@ -30,7 +31,7 @@ const IncidentsPage = () => { // Filter state const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0"); const [filter, setFilter] = useState("all"); - const [dateRange, setDateRange] = useState("recent"); + const [dateRange, setDateRange] = useState("recent"); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(10); const [activeIncidentsPage, setActiveIncidentsPage] = useState(0); diff --git a/client/src/Pages/Infrastructure/Details/index.tsx b/client/src/Pages/Infrastructure/Details/index.tsx index 202887ad5..ad1d4a334 100644 --- a/client/src/Pages/Infrastructure/Details/index.tsx +++ b/client/src/Pages/Infrastructure/Details/index.tsx @@ -10,6 +10,7 @@ import { useGet } from "@/Hooks/UseApi"; import type { HardwareDetailsResponse } from "@/Types/Monitor"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; import { useTranslation } from "react-i18next"; +import type { DateRange } from "@/Types/Query"; const InfrastructureDetails = () => { const { t } = useTranslation(); @@ -17,7 +18,7 @@ const InfrastructureDetails = () => { const { monitorId } = useParams<{ monitorId: string }>(); - const [dateRange, setDateRange] = useState("recent"); + const [dateRange, setDateRange] = useState("recent"); const [selectedTab, setSelectedTab] = useState(0); const monitorDetailsUrl = useMemo(() => { diff --git a/client/src/Pages/PageSpeed/Details/index.tsx b/client/src/Pages/PageSpeed/Details/index.tsx index ea9da4a7f..9b4e40b7c 100644 --- a/client/src/Pages/PageSpeed/Details/index.tsx +++ b/client/src/Pages/PageSpeed/Details/index.tsx @@ -15,12 +15,13 @@ import { useGet } from "@/Hooks/UseApi"; import { useParams } from "react-router-dom"; import { useTheme } from "@mui/material"; import { useState, useMemo } from "react"; +import type { DateRange } from "@/Types/Query"; const PageSpeedDetails = () => { const { monitorId } = useParams(); const isAdmin = useIsAdmin(); const theme = useTheme(); - const [dateRange, setDateRange] = useState("day"); + const [dateRange, setDateRange] = useState("day"); const monitorDetailsUrl = useMemo(() => { if (!monitorId) { diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx index bcf7177ac..13998aed5 100644 --- a/client/src/Pages/Uptime/Details/index.tsx +++ b/client/src/Pages/Uptime/Details/index.tsx @@ -31,6 +31,7 @@ import type { RootState } from "@/Types/state"; import { formatDateWithTz } from "@/Utils/TimeUtils"; import { t } from "i18next"; import { Typography } from "@mui/material"; +import type { DateRange } from "@/Types/Query"; const certificateDateFormat = "MMM D, YYYY h A"; @@ -53,7 +54,7 @@ const UptimeDetailsPage = () => { const [rowsPerPage, setRowsPerPage] = useState(5); const [geoPage, setGeoPage] = useState(0); const [geoRowsPerPage, setGeoRowsPerPage] = useState(5); - const [dateRange, setDateRange] = useState("recent"); + const [dateRange, setDateRange] = useState("recent"); const [selectedLocation, setSelectedLocation] = useState("NA"); const monitorDetailsUrl = useMemo(() => { diff --git a/client/src/Routes/index.tsx b/client/src/Routes/index.tsx index a3fccf378..37e49d1ee 100644 --- a/client/src/Routes/index.tsx +++ b/client/src/Routes/index.tsx @@ -23,7 +23,7 @@ import InfrastructureDetails from "@/Pages/Infrastructure/Details"; // Docker import Docker from "@/Pages/Docker/Monitors"; import DockerHostDetails from "@/Pages/Docker/HostDetails"; -import DockerDetails from "@/Pages/Docker/Details"; +import DockerDetails from "@/Pages/Docker/ContainerDetails"; // Checks import Checks from "@/Pages/Checks"; diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index 6e5ca29f8..419ff7736 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -139,11 +139,28 @@ export const DockerContainerStates = [ ] as const; export type DockerContainerState = (typeof DockerContainerStates)[number]; -// Mirrors DockerHealthStatuses in server/src/types/network.ts. export const DockerHealthStatuses = ["healthy", "unhealthy", "starting", "none"] as const; export type DockerHealthStatus = (typeof DockerHealthStatuses)[number]; -// Mirrors DockerContainerInfo in server/src/types/network.ts. +export const DockerPortProtocols = ["tcp", "udp", "sctp"] as const; +export type DockerPortProtocol = (typeof DockerPortProtocols)[number]; + +export interface DockerContainerPort { + privatePort: number; + protocol: DockerPortProtocol; + publicPort?: number; + hostIp?: string; +} + +export interface DockerContainerMount { + type: string; + name?: string; + source: string; + destination: string; + mode: string; + rw: boolean; +} + export interface DockerContainerInfo { id: string; name: string; @@ -151,12 +168,14 @@ export interface DockerContainerInfo { state: DockerContainerState; status: string; health: DockerHealthStatus; - cpuPct?: number; // fraction of one core; exceeds 1 when using multiple cores + cpuPct?: number; memoryUsedBytes?: number; memoryLimitBytes?: number; - memoryPct?: number; // 0-1 fraction of the memory limit + memoryPct?: number; restartCount?: number; startedAt?: string; + ports?: DockerContainerPort[]; + mounts?: DockerContainerMount[]; } export interface Check { diff --git a/client/src/Types/Query.ts b/client/src/Types/Query.ts new file mode 100644 index 000000000..6c0b819b9 --- /dev/null +++ b/client/src/Types/Query.ts @@ -0,0 +1,2 @@ +export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const; +export type DateRange = (typeof DateRanges)[number]; diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index ef48c2223..3a7778b75 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -1,7 +1,11 @@ import type { MonitorStatus, MonitorType } from "@/Types/Monitor"; import type { PaletteKey } from "@/Utils/Theme/Theme"; import type { ValueType } from "@/Components/design-elements/StatusLabel"; -import type { DockerContainerState } from "@/Types/Check"; +import type { + DockerContainerMount, + DockerContainerPort, + DockerContainerState, +} from "@/Types/Check"; export const getMonitorPath = (type: MonitorType): string => { const pathMap: Record = { @@ -111,3 +115,32 @@ export const formatUrl = (url: string, maxLength: number = 55) => { ? `${strippedUrl.slice(0, maxLength)}…` : strippedUrl; }; + +export const dedupeDockerPorts = ( + ports: DockerContainerPort[] +): DockerContainerPort[] => { + return ports.filter((port) => { + // Discard ipv6 wildcards + if (port.hostIp !== "::") return port; + + const isDuplicate = ports.some((otherPort) => { + if (otherPort.hostIp !== "0.0.0.0") return false; + + return ( + otherPort.privatePort === port.privatePort && + otherPort.protocol === port.protocol && + otherPort.publicPort === port.publicPort + ); + }); + return !isDuplicate; + }); +}; + +const isAnonymousVolumeName = (name: string): boolean => /^[0-9a-f]{64}$/.test(name); + +export const getDockerMountLabel = (mount: DockerContainerMount) => { + if (mount.type === "volume" && mount.name) + return isAnonymousVolumeName(mount.name) ? mount.name.slice(0, 12) : mount.name; + if (mount.source) return mount.source; + return mount.type; +}; diff --git a/server/openapi.json b/server/openapi.json index cd6a3466d..cf058ec4c 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -7295,6 +7295,55 @@ }, "startedAt": { "type": "string" + }, + "ports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "privatePort": { + "type": "number" + }, + "protocol": { + "type": "string", + "enum": ["tcp", "udp", "sctp"] + }, + "publicPort": { + "type": "number" + }, + "hostIp": { + "type": "string" + } + }, + "required": ["privatePort", "protocol"] + } + }, + "mounts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "source": { + "type": "string" + }, + "destination": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "rw": { + "type": "boolean" + } + }, + "required": ["type", "source", "destination", "mode", "rw"] + } } }, "required": ["id", "name", "image", "state", "status", "health"] @@ -7792,6 +7841,55 @@ }, "startedAt": { "type": "string" + }, + "ports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "privatePort": { + "type": "number" + }, + "protocol": { + "type": "string", + "enum": ["tcp", "udp", "sctp"] + }, + "publicPort": { + "type": "number" + }, + "hostIp": { + "type": "string" + } + }, + "required": ["privatePort", "protocol"] + } + }, + "mounts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "source": { + "type": "string" + }, + "destination": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "rw": { + "type": "boolean" + } + }, + "required": ["type", "source", "destination", "mode", "rw"] + } } }, "required": ["id", "name", "image", "state", "status", "health"] diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 3b94f6989..2d3a16c49 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -13,7 +13,7 @@ import { ProxyModes, } from "@/domain/monitors/monitor.type.js"; import { DateRanges, SortOrders } from "@/types/query.js"; -import { DockerContainerStates, DockerHealthStatuses } from "@/types/network.js"; +import { DockerContainerStates, DockerHealthStatuses, DockerPortProtocols } from "@/types/network.js"; const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" }); @@ -430,6 +430,23 @@ export const uptimeDetailsResponseSchema = z.object({ monitorStats: monitorStatsResponseSchema.nullable(), }); +// Keep aligned with DockerContainerPort / DockerContainerMount in types/network.ts. +export const dockerContainerPortResponseSchema = z.object({ + privatePort: z.number(), + protocol: z.enum(DockerPortProtocols), + publicPort: z.number().optional(), + hostIp: z.string().optional(), +}); + +export const dockerContainerMountResponseSchema = z.object({ + type: z.string(), + name: z.string().optional(), + source: z.string(), + destination: z.string(), + mode: z.string(), + rw: z.boolean(), +}); + // Keep aligned with DockerContainerInfo / DockerContainerSummary in types/network.ts. export const dockerContainerResponseSchema = z.object({ id: z.string(), @@ -444,6 +461,8 @@ export const dockerContainerResponseSchema = z.object({ memoryPct: z.number().optional(), restartCount: z.number().optional(), startedAt: z.string().optional(), + ports: z.array(dockerContainerPortResponseSchema).optional(), + mounts: z.array(dockerContainerMountResponseSchema).optional(), }); export const containerSummaryResponseSchema = z.object({ diff --git a/server/src/domain/checks/check.model.ts b/server/src/domain/checks/check.model.ts index a101375f4..cdab5fec7 100644 --- a/server/src/domain/checks/check.model.ts +++ b/server/src/domain/checks/check.model.ts @@ -14,7 +14,15 @@ import type { GotTimings, ILighthouseAudit, } from "@/domain/checks/check.type.js"; -import { DockerContainerInfo, DockerContainerStates, DockerContainerSummary, DockerHealthStatuses } from "@/types/network.js"; +import { + DockerContainerInfo, + DockerContainerMount, + DockerContainerPort, + DockerContainerStates, + DockerContainerSummary, + DockerHealthStatuses, + DockerPortProtocols, +} from "@/types/network.js"; type CheckMetadataDocument = Omit & { monitorId: Types.ObjectId; @@ -173,6 +181,28 @@ const auditsSchema = new Schema( { _id: false } ); +const dockerContainerPortSchema = new Schema( + { + privatePort: { type: Number, required: true }, + protocol: { type: String, enum: DockerPortProtocols, default: "tcp" }, + publicPort: { type: Number }, + hostIp: { type: String }, + }, + { _id: false } +); + +const dockerContainerMountSchema = new Schema( + { + type: { type: String, default: "" }, + name: { type: String }, + source: { type: String, default: "" }, + destination: { type: String, default: "" }, + mode: { type: String, default: "" }, + rw: { type: Boolean, default: true }, + }, + { _id: false } +); + const dockerContainerSchema = new Schema( { id: { type: String, required: true }, @@ -187,6 +217,8 @@ const dockerContainerSchema = new Schema( memoryPct: { type: Number }, restartCount: { type: Number }, startedAt: { type: String }, + ports: { type: [dockerContainerPortSchema], default: undefined }, + mounts: { type: [dockerContainerMountSchema], default: undefined }, }, { _id: false } ); diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index 152729f10..8d515b604 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -1,11 +1,15 @@ import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { DockerContainerInfo, + DockerContainerMount, + DockerContainerPort, DockerContainerState, DockerContainerStates, DockerContainerSummary, DockerHealthStatus, DockerHealthStatuses, + DockerPortProtocol, + DockerPortProtocols, DockerStatusPayload, MonitorStatusResponse, } from "@/types/network.js"; @@ -135,6 +139,8 @@ export class DockerProvider implements IStatusProvider { info.restartCount = inspectResult.value.RestartCount; info.startedAt = inspectResult.value.State?.StartedAt; info.health = this.toHealthStatus(inspectResult.value.State?.Health?.Status); + info.ports = this.toPorts(inspectResult.value.NetworkSettings?.Ports); + info.mounts = this.toMounts(inspectResult.value.Mounts); } else { this.logger.warn({ message: `Failed to inspect container ${info.name}`, @@ -152,6 +158,46 @@ export class DockerProvider implements IStatusProvider { return info; } + private toPortProtocol(protocol: string): DockerPortProtocol { + return (DockerPortProtocols as readonly string[]).includes(protocol) ? (protocol as DockerPortProtocol) : "tcp"; + } + + private toPorts(ports: Dockerode.ContainerInspectInfo["NetworkSettings"]["Ports"] | undefined): DockerContainerPort[] { + const result: DockerContainerPort[] = []; + for (const [portAndProtocol, bindings] of Object.entries(ports ?? {})) { + const [portString, protocolString = ""] = portAndProtocol.split("/"); + const privatePort = Number(portString); + if (!Number.isInteger(privatePort)) continue; + const protocol = this.toPortProtocol(protocolString); + // Exposed but unpublished ports have no bindings + if (!bindings || bindings.length === 0) { + result.push({ privatePort, protocol }); + continue; + } + for (const binding of bindings) { + const publicPort = Number(binding.HostPort); + result.push({ + privatePort, + protocol, + publicPort: Number.isInteger(publicPort) ? publicPort : undefined, + hostIp: binding.HostIp || undefined, + }); + } + } + return result; + } + + private toMounts(mounts: Dockerode.ContainerInspectInfo["Mounts"] | undefined): DockerContainerMount[] { + return (mounts ?? []).map((mount) => ({ + type: mount.Type ?? "", + name: mount.Name, + source: mount.Source ?? "", + destination: mount.Destination ?? "", + mode: mount.Mode ?? "", + rw: mount.RW ?? true, + })); + } + handle = async (monitor: Monitor): Promise> => { try { const docker = new this.DockerLib(this.toDockerOptions(monitor)); diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 25ee220ac..ae1cc78d2 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -123,6 +123,27 @@ export interface DockerContainerInfo { memoryPct?: number; // 0-1 fraction of the memory limit restartCount?: number; startedAt?: string; // ISO date + ports?: DockerContainerPort[]; + mounts?: DockerContainerMount[]; +} + +export const DockerPortProtocols = ["tcp", "udp", "sctp"] as const; +export type DockerPortProtocol = (typeof DockerPortProtocols)[number]; + +export interface DockerContainerPort { + privatePort: number; + protocol: DockerPortProtocol; + publicPort?: number; + hostIp?: string; +} + +export interface DockerContainerMount { + type: string; // open set: bind, volume, tmpfs, image, npipe, cluster, … + name?: string; + source: string; + destination: string; + mode: string; + rw: boolean; } export interface DockerStatusPayload { diff --git a/server/test/unit/providers/network/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts index 74ca2d8db..4a9e3fe52 100644 --- a/server/test/unit/providers/network/dockerProvider.test.ts +++ b/server/test/unit/providers/network/dockerProvider.test.ts @@ -375,6 +375,89 @@ describe("DockerProvider", () => { }); }); + // ── Ports and mounts ───────────────────────────────────────────────── + + describe("ports and mounts", () => { + it("maps published and unpublished ports and mounts from inspect", async () => { + const inspect = jest.fn().mockResolvedValue( + makeInspect({ + NetworkSettings: { + Ports: { + "80/tcp": [{ HostIp: "0.0.0.0", HostPort: "8080" }], + "443/tcp": null, + }, + }, + Mounts: [ + { + Type: "volume", + Name: "mongo_data", + Source: "/var/lib/docker/volumes/mongo_data/_data", + Destination: "/data/db", + Mode: "z", + RW: true, + }, + { Type: "bind", Source: "/srv/config", Destination: "/etc/app", Mode: "ro", RW: false }, + ], + }) + ); + const { provider } = setup({ inspect }); + + const result = await provider.handle(makeMonitor()); + + const container = result.payload?.containers[0]; + expect(container?.ports).toEqual([ + { privatePort: 80, protocol: "tcp", publicPort: 8080, hostIp: "0.0.0.0" }, + { privatePort: 443, protocol: "tcp" }, + ]); + expect(container?.mounts).toEqual([ + { type: "volume", name: "mongo_data", source: "/var/lib/docker/volumes/mongo_data/_data", destination: "/data/db", mode: "z", rw: true }, + { type: "bind", name: undefined, source: "/srv/config", destination: "/etc/app", mode: "ro", rw: false }, + ]); + }); + + it("emits one port entry per host binding", async () => { + const inspect = jest.fn().mockResolvedValue( + makeInspect({ + NetworkSettings: { + Ports: { + "80/tcp": [ + { HostIp: "0.0.0.0", HostPort: "8080" }, + { HostIp: "::", HostPort: "8080" }, + ], + }, + }, + }) + ); + const { provider } = setup({ inspect }); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload?.containers[0]?.ports).toEqual([ + { privatePort: 80, protocol: "tcp", publicPort: 8080, hostIp: "0.0.0.0" }, + { privatePort: 80, protocol: "tcp", publicPort: 8080, hostIp: "::" }, + ]); + }); + + it("maps missing ports and mounts to empty arrays", async () => { + const { provider } = setup(); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload?.containers[0]?.ports).toEqual([]); + expect(result.payload?.containers[0]?.mounts).toEqual([]); + }); + + it("leaves ports and mounts undefined when inspect fails", async () => { + const inspect = jest.fn().mockRejectedValue(new Error("inspect failed")); + const { provider } = setup({ inspect }); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload?.containers[0]?.ports).toBeUndefined(); + expect(result.payload?.containers[0]?.mounts).toBeUndefined(); + }); + }); + // ── Outer error handling ───────────────────────────────────────────── describe("outer error handling", () => { From cdd4953e030b00e6811929b890f9157f52ca59fc Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 2 Sep 2026 09:50:47 -0700 Subject: [PATCH 5/6] remove unused --- .../Docker/ContainerDetails/Components/StatBoxes.tsx | 3 --- .../src/Pages/Docker/ContainerDetails/TabOverview.tsx | 2 +- client/src/Pages/Docker/ContainerDetails/index.tsx | 11 +++++------ 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx b/client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx index 79bf89e16..ed97917bf 100644 --- a/client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx +++ b/client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx @@ -2,7 +2,6 @@ import Stack from "@mui/material/Stack"; import { StatBox } from "@/Components/design-elements"; // Types -import type { Monitor } from "@/Types/Monitor"; // Hooks import { useTheme } from "@mui/material"; @@ -13,10 +12,8 @@ import type { DockerContainerInfo } from "@/Types/Check"; import { formatDuration } from "@/Utils/TimeUtils"; export const DockerContainerStatusBoxes = ({ - monitor, container, }: { - monitor: Monitor | undefined; container: DockerContainerInfo | undefined; }) => { const { t } = useTranslation(); diff --git a/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx b/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx index 47503bff3..32ad93c2f 100644 --- a/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx +++ b/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx @@ -12,7 +12,7 @@ import useMediaQuery from "@mui/material/useMediaQuery"; import type { Theme } from "@mui/material"; import type { TFunction } from "i18next"; import type { DockerContainerStats } from "@/Types/Monitor"; -import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; +import { LAYOUT } from "@/Utils/Theme/constants"; import { formatPercentage } from "@/Utils/FormatUtils"; import prettyBytes from "pretty-bytes"; import { BaseBox } from "@/Components/design-elements"; diff --git a/client/src/Pages/Docker/ContainerDetails/index.tsx b/client/src/Pages/Docker/ContainerDetails/index.tsx index e3462d86e..35faef4ee 100644 --- a/client/src/Pages/Docker/ContainerDetails/index.tsx +++ b/client/src/Pages/Docker/ContainerDetails/index.tsx @@ -34,12 +34,11 @@ const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) = ? `/monitors/docker/details/${monitorId}/containers/${containerName}?dateRange=${dateRange}` : null; - const { data: containerData, refetch: refetchContainer } = - useGet( - containerDetailsUrl, - {}, - { refreshInterval: 10000, keepPreviousData: true } - ); + const { data: containerData } = useGet( + containerDetailsUrl, + {}, + { refreshInterval: 10000, keepPreviousData: true } + ); const monitor = containerData?.monitor; if (!monitor) return null; From 5657d39589ccb03572a6f850bb5007720189c405 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 2 Sep 2026 09:52:36 -0700 Subject: [PATCH 6/6] remove unused prop --- client/src/Pages/Docker/ContainerDetails/index.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/client/src/Pages/Docker/ContainerDetails/index.tsx b/client/src/Pages/Docker/ContainerDetails/index.tsx index 35faef4ee..b4583e4c1 100644 --- a/client/src/Pages/Docker/ContainerDetails/index.tsx +++ b/client/src/Pages/Docker/ContainerDetails/index.tsx @@ -57,10 +57,7 @@ const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) = name={name} image={image} /> - +