From 77974682fba6424e103d4c95b572723e02d6d09b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 2 Sep 2026 10:00:37 -0700 Subject: [PATCH 01/23] add docker log/payload types --- server/src/types/network.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/server/src/types/network.ts b/server/src/types/network.ts index ae1cc78d2..49df868d2 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -103,6 +103,20 @@ export type DockerContainerState = (typeof DockerContainerStates)[number]; export const DockerHealthStatuses = ["healthy", "unhealthy", "starting", "none"] as const; export type DockerHealthStatus = (typeof DockerHealthStatuses)[number]; +export const DockerLogStreams = ["stdout", "stderr"] as const; +export type DockerLogStream = (typeof DockerLogStreams)[number]; +export interface DockerLogLine { + ts: string; + stream: DockerLogStream; + text: string; +} + +export interface DockerContainerLogs { + containerId: string; + containerName: string; + lines: DockerLogLine[]; +} + export interface DockerContainerSummary { total: number; running: number; @@ -149,6 +163,7 @@ export interface DockerContainerMount { export interface DockerStatusPayload { containers: DockerContainerInfo[]; summary: DockerContainerSummary; + logs?: DockerContainerLogs[]; } export interface PortStatusPayload { From efcc7829d4d85e4be8958393736a3461e5806c3e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 2 Sep 2026 10:08:48 -0700 Subject: [PATCH 02/23] move docker types to docker domain --- .../src/api/validation/monitorValidation.ts | 2 +- server/src/domain/checks/check.model.ts | 2 +- server/src/domain/checks/check.snapshot.ts | 2 +- server/src/domain/checks/check.type.ts | 2 +- server/src/domain/docker/docker.type.ts | 65 +++++++++++++++++++ server/src/service/network/DockerProvider.ts | 6 +- server/src/types/network.ts | 63 +----------------- 7 files changed, 73 insertions(+), 69 deletions(-) create mode 100644 server/src/domain/docker/docker.type.ts diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 2d3a16c49..09f85401c 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, DockerPortProtocols } from "@/types/network.js"; +import { DockerContainerStates, DockerHealthStatuses, DockerPortProtocols } from "@/domain/docker/docker.type.js"; const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" }); diff --git a/server/src/domain/checks/check.model.ts b/server/src/domain/checks/check.model.ts index cdab5fec7..a4319056e 100644 --- a/server/src/domain/checks/check.model.ts +++ b/server/src/domain/checks/check.model.ts @@ -22,7 +22,7 @@ import { DockerContainerSummary, DockerHealthStatuses, DockerPortProtocols, -} from "@/types/network.js"; +} from "@/domain/docker/docker.type.js"; type CheckMetadataDocument = Omit & { monitorId: Types.ObjectId; diff --git a/server/src/domain/checks/check.snapshot.ts b/server/src/domain/checks/check.snapshot.ts index 7348a2d55..65ef4f60a 100644 --- a/server/src/domain/checks/check.snapshot.ts +++ b/server/src/domain/checks/check.snapshot.ts @@ -11,7 +11,7 @@ import type { SnapshotHostInfo, SnapshotMemoryInfo, } from "@/domain/checks/check.type.js"; -import { DockerContainerSummary } from "@/types/network.js"; +import { DockerContainerSummary } from "@/domain/docker/docker.type.js"; import { toDateString } from "@/utils/mongoMappers.js"; export type CheckSnapshotSource = Pick< diff --git a/server/src/domain/checks/check.type.ts b/server/src/domain/checks/check.type.ts index feeb4439a..afea301be 100644 --- a/server/src/domain/checks/check.type.ts +++ b/server/src/domain/checks/check.type.ts @@ -1,5 +1,5 @@ import type { MonitorType } from "@/domain/monitors/monitor.type.js"; -import { DockerContainerInfo, DockerContainerSummary } from "@/types/network.js"; +import { DockerContainerInfo, DockerContainerSummary } from "@/domain/docker/docker.type.js"; import type { Response } from "got"; export const CHECK_TTL_SENTINEL = 366; diff --git a/server/src/domain/docker/docker.type.ts b/server/src/domain/docker/docker.type.ts new file mode 100644 index 000000000..66504e273 --- /dev/null +++ b/server/src/domain/docker/docker.type.ts @@ -0,0 +1,65 @@ +export const DockerContainerStates = ["created", "running", "paused", "restarting", "removing", "exited", "dead"] as const; +export type DockerContainerState = (typeof DockerContainerStates)[number]; + +export const DockerHealthStatuses = ["healthy", "unhealthy", "starting", "none"] as const; +export type DockerHealthStatus = (typeof DockerHealthStatuses)[number]; + +export const DockerPortProtocols = ["tcp", "udp", "sctp"] as const; +export type DockerPortProtocol = (typeof DockerPortProtocols)[number]; + +export const DockerLogStreams = ["stdout", "stderr"] as const; +export type DockerLogStream = (typeof DockerLogStreams)[number]; + +export const DOCKER_LOG_TAIL_LINES = 200; + +export interface DockerLogLine { + ts: string; + stream: DockerLogStream; + text: string; +} + +export interface DockerContainerLogs { + containerId: string; + containerName: string; + lines: DockerLogLine[]; +} + +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 DockerContainerInfo { + id: string; + name: string; + image: string; + state: DockerContainerState; + status: string; + health: DockerHealthStatus; + cpuPct?: number; // fraction of one core (docker stats convention / 100); exceeds 1 when using multiple cores + memoryUsedBytes?: number; + memoryLimitBytes?: number; + memoryPct?: number; // 0-1 fraction of the memory limit + restartCount?: number; + startedAt?: string; // ISO date + ports?: DockerContainerPort[]; + mounts?: DockerContainerMount[]; +} + +export interface DockerContainerSummary { + total: number; + running: number; + stopped: number; + unhealthy: number; +} diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index 8d515b604..7b6c98e7c 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -1,4 +1,6 @@ import { IStatusProvider } from "@/service/network/IStatusProvider.js"; +import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js"; + import { DockerContainerInfo, DockerContainerMount, @@ -10,9 +12,7 @@ import { DockerHealthStatuses, DockerPortProtocol, DockerPortProtocols, - DockerStatusPayload, - MonitorStatusResponse, -} from "@/types/network.js"; +} from "@/domain/docker/docker.type.js"; import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { ILogger } from "@/utils/logger.js"; import { AppError } from "@/utils/AppError.js"; diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 49df868d2..bbbd41403 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -9,6 +9,7 @@ import type { GotTimings, ILighthouseAudit, } from "@/domain/checks/check.type.js"; +import { DockerContainerInfo, DockerContainerLogs, DockerContainerSummary } from "@/domain/docker/docker.type.js"; import type { DnsRecordType, Monitor, MonitorMatchMethod, MonitorStatus, MonitorType } from "@/domain/monitors/monitor.type.js"; import type { QueryResult } from "gamedig"; @@ -97,68 +98,6 @@ export interface HardwareStatusPayload { } // Docker host monitoring -export const DockerContainerStates = ["created", "running", "paused", "restarting", "removing", "exited", "dead"] as const; -export type DockerContainerState = (typeof DockerContainerStates)[number]; - -export const DockerHealthStatuses = ["healthy", "unhealthy", "starting", "none"] as const; -export type DockerHealthStatus = (typeof DockerHealthStatuses)[number]; - -export const DockerLogStreams = ["stdout", "stderr"] as const; -export type DockerLogStream = (typeof DockerLogStreams)[number]; -export interface DockerLogLine { - ts: string; - stream: DockerLogStream; - text: string; -} - -export interface DockerContainerLogs { - containerId: string; - containerName: string; - lines: DockerLogLine[]; -} - -export interface DockerContainerSummary { - total: number; - running: number; - stopped: number; - unhealthy: number; -} - -export interface DockerContainerInfo { - id: string; - name: string; - image: string; - state: DockerContainerState; - status: string; - health: DockerHealthStatus; - cpuPct?: number; // fraction of one core (docker stats convention / 100); exceeds 1 when using multiple cores - memoryUsedBytes?: number; - memoryLimitBytes?: number; - 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 { containers: DockerContainerInfo[]; From 3e06cd39b10bb350eb1bb6a136ad34e525dff7f6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 2 Sep 2026 11:09:38 -0700 Subject: [PATCH 03/23] add docker logs to provider --- server/src/service/network/DockerProvider.ts | 87 +++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index 7b6c98e7c..4e0d5e921 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -2,7 +2,9 @@ import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js"; import { + DOCKER_LOG_TAIL_LINES, DockerContainerInfo, + DockerContainerLogs, DockerContainerMount, DockerContainerPort, DockerContainerState, @@ -10,6 +12,8 @@ import { DockerContainerSummary, DockerHealthStatus, DockerHealthStatuses, + DockerLogLine, + DockerLogStream, DockerPortProtocol, DockerPortProtocols, } from "@/domain/docker/docker.type.js"; @@ -23,6 +27,9 @@ type DockerodeType = typeof Dockerode; const SERVICE_NAME = "DockerProvider"; const STATS_CONCURRENCY = 5; +const DOCKER_LOG_MAX_LINE_BYTES = 4096; +const DOCKER_LOG_TRUNCATION_MARKER = " …[truncated]"; +const DOCKER_LOG_TS_REGEX = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z /; export interface DockerError extends Error { statusCode?: number; @@ -119,10 +126,14 @@ export class DockerProvider implements IStatusProvider { }; } + private toContainerName = (summary: Dockerode.ContainerInfo): string => { + return summary.Names?.[0]?.replace(/^\//, "") ?? summary.Id.slice(0, 12); + }; + private async toContainerInfo(docker: Dockerode, summary: Dockerode.ContainerInfo): Promise { const info: DockerContainerInfo = { id: summary.Id, - name: summary.Names?.[0]?.replace(/^\//, "") ?? summary.Id.slice(0, 12), + name: this.toContainerName(summary), image: summary.Image, state: this.toContainerState(summary.State), status: summary.Status, @@ -198,6 +209,76 @@ export class DockerProvider implements IStatusProvider { })); } + // Log parsing + private isMultiplexed(buffer: Buffer): boolean { + return buffer.length >= 8 && buffer.readUInt8(0) <= 2 && buffer.readUInt8(1) === 0 && buffer.readUInt8(2) === 0 && buffer.readUInt8(3) === 0; + } + + // Docker will will merge stdout and stderr if stared with `-t`. + // Most containers will _not_ be run with `-t` though, in which case logs come in frames of [header][payload] + private demuxLogBuffer = (buffer: Buffer): { stream: DockerLogStream; chunk: string }[] => { + if (!this.isMultiplexed(buffer)) return [{ stream: "stdout", chunk: buffer.toString("utf8") }]; // `-t` case, log is just a string, return it + const frames: { stream: DockerLogStream; chunk: string }[] = []; + let offset = 0; // Start reading at the first byte of the first header + while (offset + 8 <= buffer.length) { + // Every 8 bytes is a header, if < 8 bytes at the end, invalid frame, stop + const streamType = buffer.readUint8(offset); // First byte of the header is the stream type: 0 = stdin, 1 = stdout, 2 = stderr + const payloadSize = buffer.readUint32BE(offset + 4); // Byte 4 to 7 describe how many payload bytes follow the header + const payloadStart = offset + 8; // Advance to the payload (header 8 bytes long) + const payloadEnd = Math.min(payloadStart + payloadSize, buffer.length); // End is the start of the payload + payload size. + frames.push({ stream: streamType === 2 ? "stderr" : "stdout", chunk: buffer.toString("utf-8", payloadStart, payloadEnd) }); + offset = payloadEnd; + } + return frames; + }; + + private truncateLog = (text: string): string => { + if (Buffer.byteLength(text, "utf8") <= DOCKER_LOG_MAX_LINE_BYTES) return text; + const cut = Buffer.from(text, "utf8").subarray(0, DOCKER_LOG_MAX_LINE_BYTES).toString("utf8").replace(/�$/, ""); + return cut + DOCKER_LOG_TRUNCATION_MARKER; + }; + + // Docker drops trailing zeroes form timestamps. Pad out timestamps for correct string comparison + private toLogLine(stream: DockerLogStream, raw: string): DockerLogLine | null { + const match = raw.match(DOCKER_LOG_TS_REGEX); + if (!match) return null; // If there's no time stamp, something is wrong with this line, drop it + const ts = `${match[1]}.${(match[2] ?? "").padEnd(9, "0")}Z`; + const text = raw.slice(match[0].length).replace(/\r$/, ""); + return { ts, stream, text: this.truncateLog(text) }; + } + + private parseLogBuffer = (buffer: Buffer): DockerLogLine[] => { + const lines: DockerLogLine[] = []; + const demuxedBuffer = this.demuxLogBuffer(buffer); + for (const byteStream of demuxedBuffer) { + const { stream, chunk } = byteStream; + for (const raw of chunk.split("/n")) { + if (raw.length === 0) continue; + const line = this.toLogLine(stream, raw); + if (line) lines.push(line); + } + } + return lines; + }; + + private toContainerLogs = async (docker: Dockerode, summary: Dockerode.ContainerInfo): Promise => { + const containerName = this.toContainerName(summary); + try { + const buffer = await docker + .getContainer(summary.Id) + .logs({ follow: false, stdout: true, stderr: true, timestamps: true, tail: DOCKER_LOG_TAIL_LINES }); + return { containerId: summary.Id, containerName, lines: this.parseLogBuffer(buffer) }; + } catch (error: unknown) { + this.logger.warn({ + message: `Failed to read logs for container ${containerName}`, + service: SERVICE_NAME, + method: "toContainerLogs", + details: { containerId: summary.Id, error: error instanceof Error ? error.message : String(error) }, + }); + return null; + } + }; + handle = async (monitor: Monitor): Promise> => { try { const docker = new this.DockerLib(this.toDockerOptions(monitor)); @@ -226,6 +307,8 @@ export class DockerProvider implements IStatusProvider { const summaries = await docker.listContainers({ all: true }); const containers = await this.mapWithConcurrency(summaries, STATS_CONCURRENCY, (s) => this.toContainerInfo(docker, s)); + const rawLogs = await this.mapWithConcurrency(summaries, STATS_CONCURRENCY, (s) => this.toContainerLogs(docker, s)); + const logs = rawLogs.filter((log) => log !== null); const summary: DockerContainerSummary = { total: containers.length, running: containers.filter((c) => c.state === "running").length, @@ -240,7 +323,7 @@ export class DockerProvider implements IStatusProvider { code: 200, message: "Docker host is reachable", responseTime, - payload: { containers, summary }, + payload: { containers, summary, logs }, }; } catch (error: unknown) { if (error instanceof AppError) throw error; From 6ea4ca1aa939e7787513f790d544590ee7476f64 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 2 Sep 2026 11:56:18 -0700 Subject: [PATCH 04/23] new docker-log domain,service, repo --- server/src/domain/docker/docker-log.model.ts | 60 ++++++++++++ .../docker/docker-log.repository.interface.ts | 10 ++ .../docker/docker-log.repository.mongo.ts | 93 ++++++++++++++++++ .../src/domain/docker/docker-log.service.ts | 97 +++++++++++++++++++ server/src/domain/docker/docker-log.type.ts | 28 ++++++ 5 files changed, 288 insertions(+) create mode 100644 server/src/domain/docker/docker-log.model.ts create mode 100644 server/src/domain/docker/docker-log.repository.interface.ts create mode 100644 server/src/domain/docker/docker-log.repository.mongo.ts create mode 100644 server/src/domain/docker/docker-log.service.ts create mode 100644 server/src/domain/docker/docker-log.type.ts diff --git a/server/src/domain/docker/docker-log.model.ts b/server/src/domain/docker/docker-log.model.ts new file mode 100644 index 000000000..269733ecd --- /dev/null +++ b/server/src/domain/docker/docker-log.model.ts @@ -0,0 +1,60 @@ +import { Schema, model, Types } from "mongoose"; +import { DockerLogStreams, type DockerLogLine } from "@/domain/docker/docker.type.js"; +import type { DockerLog, DockerLogMetadata } from "@/domain/docker/docker-log.type.js"; + +type DockerLogMetadataDocument = Omit & { + monitorId: Types.ObjectId; + teamId: Types.ObjectId; +}; + +type DockerLogDocumentBase = Omit & { + metadata: DockerLogMetadataDocument; + checkedAt: Date; + expiry: Date; + createdAt: Date; + updatedAt: Date; +}; + +export interface DockerLogDocument extends DockerLogDocumentBase { + _id: Types.ObjectId; +} + +const dockerLogMetadataSchema = new Schema( + { + monitorId: { type: Schema.Types.ObjectId, required: true }, + teamId: { type: Schema.Types.ObjectId, required: true }, + containerId: { type: String, required: true }, + containerName: { type: String, required: true }, + }, + { _id: false } +); + +const dockerLogLineSchema = new Schema( + { + ts: { type: String, required: true }, + stream: { type: String, enum: DockerLogStreams, required: true }, + text: { type: String, default: "" }, + }, + { _id: false } +); + +const DockerLogSchema = new Schema( + { + metadata: { type: dockerLogMetadataSchema, required: true }, + lines: { type: [dockerLogLineSchema], required: true, default: [] }, + gap: { type: Boolean, default: false }, + checkedAt: { type: Date, required: true }, + expiry: { type: Date, required: true, index: { expires: 0 } }, // TTL: Mongo deletes the doc once `expiry` passes + }, + { timestamps: true } +); + +DockerLogSchema.index({ "metadata.monitorId": 1, "metadata.containerName": 1, checkedAt: -1 }); // API paging +DockerLogSchema.index({ "metadata.monitorId": 1, "metadata.containerId": 1, checkedAt: -1 }); // cursor seed +DockerLogSchema.index({ "metadata.teamId": 1 }); + +const DockerLogModel = model("DockerLog", DockerLogSchema); + +export type { DockerLogMetadataDocument }; +export { DockerLogModel }; +export default DockerLogModel; diff --git a/server/src/domain/docker/docker-log.repository.interface.ts b/server/src/domain/docker/docker-log.repository.interface.ts new file mode 100644 index 000000000..59745ba4a --- /dev/null +++ b/server/src/domain/docker/docker-log.repository.interface.ts @@ -0,0 +1,10 @@ +import { DockerLog } from "@/domain/docker/docker-log.type.js"; + +export interface IDockerLogsRepository { + createDockerLogs(logs: Omit[]): Promise; + findLastLineTimestamp(monitorId: string, containerId: string): Promise; + findByContainerName(args: { monitorId: string; containerName: string; before?: Date; limit: number }): Promise; + deleteByMonitorId(monitorId: string): Promise; + deleteByTeamId(teamId: string): Promise; + deleteByMonitorIdsNotIn(monitorIds: string[]): Promise; +} diff --git a/server/src/domain/docker/docker-log.repository.mongo.ts b/server/src/domain/docker/docker-log.repository.mongo.ts new file mode 100644 index 000000000..bbe9ec002 --- /dev/null +++ b/server/src/domain/docker/docker-log.repository.mongo.ts @@ -0,0 +1,93 @@ +import DockerLogModel, { DockerLogDocument } from "@/domain/docker/docker-log.model.js"; +import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js"; +import { DockerLog } from "@/domain/docker/docker-log.type.js"; +import { toDateString, toStringId } from "@/utils/mongoMappers.js"; +import mongoose from "mongoose"; + +const SERVICE_NAME = "DockerLogsRepository"; + +class MongoDockerLogsRepository implements IDockerLogsRepository { + static SERVICE_NAME = SERVICE_NAME; + + private toEntity = (doc: DockerLogDocument): DockerLog => { + return { + id: toStringId(doc._id), + metadata: { + monitorId: toStringId(doc.metadata.monitorId), + teamId: toStringId(doc.metadata.teamId), + containerId: doc.metadata.containerId, + containerName: doc.metadata.containerName, + }, + lines: (doc.lines ?? []).map((line) => ({ ts: line.ts, stream: line.stream, text: line.text ?? "" })), + gap: doc.gap ?? false, + checkedAt: toDateString(doc.checkedAt), + expiry: toDateString(doc.expiry), + createdAt: toDateString(doc.createdAt), + updatedAt: toDateString(doc.updatedAt), + }; + }; + + createDockerLogs = async (logs: Omit[]): Promise => { + if (logs.length === 0) return 0; + const docs = await DockerLogModel.insertMany( + logs.map((log) => { + return { + metadata: { + monitorId: new mongoose.Types.ObjectId(log.metadata.monitorId), + teamId: new mongoose.Types.ObjectId(log.metadata.teamId), + containerId: log.metadata.containerId, + containerName: log.metadata.containerName, + }, + lines: log.lines, + gap: log.gap, + checkedAt: new Date(log.checkedAt), + expiry: new Date(log.expiry), + }; + }), + { ordered: false } + ); + return docs.length; + }; + + findLastLineTimestamp = async (monitorId: string, containerId: string): Promise => { + const doc = await DockerLogModel.findOne({ + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + "metadata.containerId": containerId, + }) + .sort({ checkedAt: -1 }) + .select({ lines: { $slice: -1 } }) + .lean>(); + return doc?.lines?.[0]?.ts ?? null; + }; + + findByContainerName = async ({ + monitorId, + containerName, + before, + limit, + }: { + monitorId: string; + containerName: string; + before?: Date; + limit: number; + }): Promise => { + const docs = await DockerLogModel.find({ + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + "metadata.containerName": containerName, + ...(before ? { checkedAt: { $lt: before } } : {}), + }) + .sort({ checkedAt: -1 }) + .limit(limit) + .lean(); + return docs.map(this.toEntity); + }; + + deleteByMonitorId = async (monitorId: string) => + (await DockerLogModel.deleteMany({ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId) })).deletedCount ?? 0; + + deleteByTeamId = async (teamId: string) => + (await DockerLogModel.deleteMany({ "metadata.teamId": new mongoose.Types.ObjectId(teamId) })).deletedCount ?? 0; + + deleteByMonitorIdsNotIn = async (monitorIds: string[]) => + (await DockerLogModel.deleteMany({ "metadata.monitorId": { $nin: monitorIds.map((id) => new mongoose.Types.ObjectId(id)) } })).deletedCount ?? 0; +} diff --git a/server/src/domain/docker/docker-log.service.ts b/server/src/domain/docker/docker-log.service.ts new file mode 100644 index 000000000..0f914dde3 --- /dev/null +++ b/server/src/domain/docker/docker-log.service.ts @@ -0,0 +1,97 @@ +import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js"; +import { DOCKER_LOG_RETENTION_DAYS, DockerLog } from "@/domain/docker/docker-log.type.js"; +import { DOCKER_LOG_TAIL_LINES, DockerLogLine } from "@/domain/docker/docker.type.js"; +import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js"; +import { ILogger } from "@/utils/logger.js"; +import mongoose from "mongoose"; + +const SERVICE_NAME = "DockerLogsService"; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +export interface IDockerLogsService { + buildDockerLogs(status: MonitorStatusResponse): Promise; + createDockerLogs(logs: DockerLog[]): Promise; +} + +export class DockerLogsSerivce implements IDockerLogsService { + static SERVICE_NAME = SERVICE_NAME; + + private logger: ILogger; + private dockerLogsRepository: IDockerLogsRepository; + + private lastTimestampByContainer = new Map(); + + constructor({ logger, dockerLogsRepository }: { logger: ILogger; dockerLogsRepository: IDockerLogsRepository }) { + this.logger = logger; + this.dockerLogsRepository = dockerLogsRepository; + } + + private generateCursorKey = (monitorId: string, containerId: string) => `${monitorId}:${containerId}`; + + private getLastTimestamp = async (monitorId: string, containerId: string) => { + const key = this.generateCursorKey(monitorId, containerId); + const cached = this.lastTimestampByContainer.get(key); + if (cached !== undefined) return cached || null; + const stored = await this.dockerLogsRepository.findLastLineTimestamp(monitorId, containerId); + this.lastTimestampByContainer.set(key, stored ?? ""); + return stored; + }; + + private newLines = (lines: DockerLogLine[], lastTimestamp: string | null): DockerLogLine[] => { + return lastTimestamp + ? lines.filter((line) => { + return line.ts > lastTimestamp; + }) + : lines; + }; + + private maxTimestamp = (lines: DockerLogLine[]) => { + return lines.reduce((max, line) => (line.ts > max ? line.ts : max), ""); + }; + + buildDockerLogs = async (status: MonitorStatusResponse): Promise => { + const payload = typeof status.payload === "object" ? status.payload : null; + const containerLogs = payload?.logs ?? []; + if (containerLogs.length === 0) return []; + + const now = new Date(); + const checkedAt = now.toISOString(); + const expiry = new Date(now.getTime() + DOCKER_LOG_RETENTION_DAYS * MS_PER_DAY).toISOString(); + const results: DockerLog[] = []; + + for (const dockerContainerLog of containerLogs) { + const { containerId, containerName, lines } = dockerContainerLog; + try { + const lastTimestamp = await this.getLastTimestamp(status.monitorId, containerId); + const fresh = this.newLines(lines, lastTimestamp); + if (fresh.length === 0) continue; + this.lastTimestampByContainer.set(this.generateCursorKey(status.monitorId, containerId), this.maxTimestamp(fresh)); + results.push({ + id: new mongoose.Types.ObjectId().toString(), + metadata: { + monitorId: status.monitorId, + teamId: status.teamId, + containerId, + containerName, + }, + lines: fresh, + gap: fresh.length === DOCKER_LOG_TAIL_LINES, + checkedAt, + expiry, + createdAt: checkedAt, + updatedAt: checkedAt, + }); + } catch (error: unknown) { + this.logger.warn({ + message: `Failed to build docker logs for container ${containerName}`, + service: SERVICE_NAME, + method: "buildDockerLogs", + details: { monitorId: status.monitorId, containerId, error: error instanceof Error ? error.message : String(error) }, + }); + } + } + return results; + }; + + createDockerLogs = async (logs: DockerLog[]): Promise => this.dockerLogsRepository.createDockerLogs(logs); +} diff --git a/server/src/domain/docker/docker-log.type.ts b/server/src/domain/docker/docker-log.type.ts new file mode 100644 index 000000000..28448f41c --- /dev/null +++ b/server/src/domain/docker/docker-log.type.ts @@ -0,0 +1,28 @@ +import { DockerLogLine } from "@/domain/docker/docker.type.js"; + +export const DOCKER_LOG_RETENTION_DAYS = 7; +export const DOCKER_LOG_PAGE_DEFAULT = 20; +export const DOCKER_LOG_PAGE_MAX = 50; + +export interface DockerLogMetadata { + monitorId: string; + teamId: string; + containerId: string; + containerName: string; +} + +export interface DockerLog { + id: string; + metadata: DockerLogMetadata; + lines: DockerLogLine[]; + gap: boolean; + checkedAt: string; + expiry: string; + createdAt: string; + updatedAt: string; +} + +export interface DockerLogPage { + logs: DockerLog[]; + nextCursor: string | null; +} From 700baf20610ebe576290f4011eb428b8cd49b6e9 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 3 Sep 2026 09:19:08 -0700 Subject: [PATCH 05/23] add compile script --- client/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/package.json b/client/package.json index ce2e481ba..365581866 100644 --- a/client/package.json +++ b/client/package.json @@ -12,7 +12,8 @@ "lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", "format": "prettier --write .", - "format-check": "prettier --check ." + "format-check": "prettier --check .", + "compile": "tsc -b" }, "lint-staged": { "**/*": "prettier --write --ignore-unknown" From 6587ddf38965326f9d1000beeb24db21a9e7f9c5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 3 Sep 2026 11:32:48 -0700 Subject: [PATCH 06/23] wire up services/repositories --- .../src/api/controllers/monitorController.ts | 25 +++++++++ server/src/api/routes/monitorRoutes.ts | 1 + .../src/api/validation/monitorValidation.ts | 31 ++++++++++- server/src/config/services.api.ts | 2 + server/src/config/services.shared.ts | 9 ++++ server/src/config/services.worker.ts | 8 ++- .../docker/docker-log.repository.mongo.ts | 2 + .../src/domain/docker/docker-log.service.ts | 2 +- server/src/domain/monitors/monitor.service.ts | 53 +++++++++++++++++++ server/src/domain/monitors/monitor.type.ts | 3 ++ server/src/service/bufferService.ts | 48 +++++++++++++++++ server/src/service/network/DockerProvider.ts | 2 +- server/src/worker/worker.check-producer.ts | 12 ++++- server/src/worker/worker.helper.ts | 15 +++++- 14 files changed, 206 insertions(+), 7 deletions(-) diff --git a/server/src/api/controllers/monitorController.ts b/server/src/api/controllers/monitorController.ts index af73d786a..1da1d430e 100644 --- a/server/src/api/controllers/monitorController.ts +++ b/server/src/api/controllers/monitorController.ts @@ -21,6 +21,7 @@ import { getDockerDetailsByIdQueryValidation, getDockerContainerNameParamValidation, getDockerContainerByNameQueryValidation, + getDockerContainerLogsQueryValidation, } from "@/api/validation/monitorValidation.js"; import sslChecker from "ssl-checker"; import * as whoiser from "whoiser"; @@ -37,6 +38,7 @@ export interface IMonitorController { getPageSpeedDetailsById: RequestHandler; getDockerDetailsById: RequestHandler; getDockerContainerByName: RequestHandler; + getDockerContainerLogs: RequestHandler; getGeoChecksByMonitorId: RequestHandler; getMonitorById: RequestHandler; createMonitor: RequestHandler; @@ -198,6 +200,29 @@ class MonitorController implements IMonitorController { }); }); + getDockerContainerLogs = catchAsync(async (req: Request, res: Response) => { + const validatedParams = getDockerContainerNameParamValidation.parse(req.params); + const validatedQuery = getDockerContainerLogsQueryValidation.parse(req.query); + + const { monitorId, containerName } = validatedParams; + const { before, limit } = validatedQuery; + const teamId = requireTeamId(req.user?.teamId); + + const data = await this.monitorService.getDockerContainerLogs({ + teamId, + monitorId, + containerName, + before: before ? new Date(before) : undefined, + limit, + }); + + return res.status(200).json({ + success: true, + msg: "Docker container logs 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 5192f6619..dab4614cd 100644 --- a/server/src/api/routes/monitorRoutes.ts +++ b/server/src/api/routes/monitorRoutes.ts @@ -21,6 +21,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); + router.get("/docker/details/:monitorId/containers/:containerName/logs", monitorController.getDockerContainerLogs); // 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 09f85401c..32d2abee2 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -13,7 +13,8 @@ import { ProxyModes, } from "@/domain/monitors/monitor.type.js"; import { DateRanges, SortOrders } from "@/types/query.js"; -import { DockerContainerStates, DockerHealthStatuses, DockerPortProtocols } from "@/domain/docker/docker.type.js"; +import { DockerContainerStates, DockerHealthStatuses, DockerLogStreams, DockerPortProtocols } from "@/domain/docker/docker.type.js"; +import { DOCKER_LOG_PAGE_DEFAULT, DOCKER_LOG_PAGE_MAX } from "@/domain/docker/docker-log.type.js"; const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" }); @@ -332,6 +333,11 @@ export const getDockerContainerNameParamValidation = z.object({ }); export const getDockerContainerByNameQueryValidation = z.object({ dateRange: z.enum(DateRanges).optional() }); +export const getDockerContainerLogsQueryValidation = z.object({ + before: z.iso.datetime().optional(), + limit: z.coerce.number().int().min(1).max(DOCKER_LOG_PAGE_MAX).default(DOCKER_LOG_PAGE_DEFAULT), +}); + // Canonical monitor shape returned by /monitors endpoints. Keep aligned with // what the controllers actually serialize. export const monitorResponseSchema = z @@ -529,3 +535,26 @@ export const dockerContainerDetailsResponseSchema = z.object({ .nullable(), }), }); + +export const dockerLogLineResponseSchema = z.object({ + ts: z.string(), + stream: z.enum(DockerLogStreams), + text: z.string(), +}); + +export const dockerLogResponseSchema = z.object({ + id: z.string(), + metadata: z.object({ monitorId: z.string(), teamId: z.string(), containerId: z.string(), containerName: z.string() }), + lines: z.array(dockerLogLineResponseSchema), + gap: z.boolean(), + checkedAt: z.string(), + expiry: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +// Response of GET /monitors/docker/details/{monitorId}/containers/{containerName}/logs. +export const dockerContainerLogsResponseSchema = z.object({ + logs: z.array(dockerLogResponseSchema), + nextCursor: z.string().nullable(), +}); diff --git a/server/src/config/services.api.ts b/server/src/config/services.api.ts index 5ce76aa3d..65607107c 100644 --- a/server/src/config/services.api.ts +++ b/server/src/config/services.api.ts @@ -35,6 +35,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A monitorsRepository, checksRepository, geoChecksRepository, + dockerLogsRepository, monitorStatsRepository, statusPagesRepository, usersRepository, @@ -75,6 +76,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A monitorsRepository, checksRepository, geoChecksRepository, + dockerLogsRepository, monitorStatsRepository, statusPagesRepository, incidentsRepository, diff --git a/server/src/config/services.shared.ts b/server/src/config/services.shared.ts index 7ea873c4b..76edfbc99 100644 --- a/server/src/config/services.shared.ts +++ b/server/src/config/services.shared.ts @@ -13,6 +13,7 @@ import { IDb } from "@/db/db.interface.js"; import { ISettingsService, EnvConfig, SettingsService } from "@/domain/app-settings/app-settings.service.js"; import { ICheckService, CheckService } from "@/domain/checks/check.service.js"; import { IGeoChecksService, GeoChecksService } from "@/domain/geo-checks/geo-check.service.js"; +import { IDockerLogsService, DockerLogsService } from "@/domain/docker/docker-log.service.js"; import { IIncidentService, IncidentService } from "@/domain/incidents/incident.service.js"; import { INotificationMessageBuilder, NotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js"; import { INotificationsService, NotificationsService } from "@/domain/notifications/notification.service.js"; @@ -58,6 +59,7 @@ import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface. import MongoSettingsRepository from "@/domain/app-settings/app-settings.repository.mongo.js"; import MongoChecksRepository from "@/domain/checks/check.repository.mongo.js"; import MongoGeoChecksRepository from "@/domain/geo-checks/geo-check.repository.mongo.js"; +import MongoDockerLogsRepository from "@/domain/docker/docker-log.repository.mongo.js"; import MongoIncidentsRepository from "@/domain/incidents/incident.repository.mongo.js"; import MongoInvitesRepository from "@/domain/invites/invite.repository.mongo.js"; import MongoJobsRepository from "@/domain/jobs/job.repository.mongo.js"; @@ -72,6 +74,7 @@ import MongoTagsRepository from "@/domain/tags/tag.repository.mongo.js"; import MongoTeamsRepository from "@/domain/teams/team.repository.model.js"; import MongoUsersRepository from "@/domain/users/user.repository.mongo.js"; import MongoProxiesRepository from "@/domain/proxies/proxy.repository.mongo.js"; +import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js"; // Shared infrastructure + business services that both the API and the worker process construct. export interface SharedServices { @@ -83,6 +86,7 @@ export interface SharedServices { incidentService: IIncidentService; checkService: ICheckService; geoChecksService: IGeoChecksService; + dockerLogsService: IDockerLogsService; notificationsService: INotificationsService; // Queue identity (one per process; the worker reuses these) @@ -94,6 +98,7 @@ export interface SharedServices { monitorsRepository: IMonitorsRepository; checksRepository: IChecksRepository; geoChecksRepository: IGeoChecksRepository; + dockerLogsRepository: IDockerLogsRepository; monitorStatsRepository: IMonitorStatsRepository; statusPagesRepository: IStatusPagesRepository; usersRepository: IUsersRepository; @@ -131,6 +136,7 @@ export const buildShared = async ({ const incidentsRepository = new MongoIncidentsRepository(); const usersRepository = new MongoUsersRepository(); const geoChecksRepository = new MongoGeoChecksRepository(); + const dockerLogsRepository = new MongoDockerLogsRepository(); const notificationsRepository = new MongoNotificationsRepository(); const jobsRepository = new MongoJobsRepository(workerId); const queueWorkersRepository = new MongoQueueWorkersRepository(); @@ -161,6 +167,7 @@ export const buildShared = async ({ monitorsRepository, }); + const dockerLogsService = new DockerLogsService({ logger, dockerLogsRepository }); const webhookProvider = new WebhookProvider(logger); const rocketChatProvider = new RocketChatProvider(logger); const slackProvider = new SlackProvider(logger); @@ -207,6 +214,7 @@ export const buildShared = async ({ incidentService, checkService, geoChecksService, + dockerLogsService, notificationsService, workerId, jobsRepository, @@ -214,6 +222,7 @@ export const buildShared = async ({ queueWorkersRepository, checksRepository, geoChecksRepository, + dockerLogsRepository, monitorStatsRepository, statusPagesRepository, usersRepository, diff --git a/server/src/config/services.worker.ts b/server/src/config/services.worker.ts index 9b4ea8609..edea2b045 100644 --- a/server/src/config/services.worker.ts +++ b/server/src/config/services.worker.ts @@ -52,6 +52,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig settingsService, checkService, geoChecksService, + dockerLogsService, notificationsService, incidentService, workerId, @@ -60,6 +61,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig monitorsRepository, checksRepository, geoChecksRepository, + dockerLogsRepository, monitorStatsRepository, incidentsRepository, teamsRepository, @@ -95,7 +97,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig ]); const proxyResolver = new ProxyResolver(proxiesRepository, settingsService, logger); - const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService, jobsRepository); + const bufferService = new BufferService(logger, checkService, geoChecksService, dockerLogsService, settingsService, jobsRepository); const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository); const monitorStatusPolicy = new MonitorStatusPolicy(); @@ -119,6 +121,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig networkService, proxyResolver, bufferService, + dockerLogsService, logger ); const checkEvaluator = new CheckEvaluator(statusService, monitorStatusPolicy); @@ -138,7 +141,8 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig monitorStatsRepository, checksRepository, incidentsRepository, - geoChecksRepository + geoChecksRepository, + dockerLogsRepository ); const worker = await DBQueueWorker.create({ diff --git a/server/src/domain/docker/docker-log.repository.mongo.ts b/server/src/domain/docker/docker-log.repository.mongo.ts index bbe9ec002..f67d87a04 100644 --- a/server/src/domain/docker/docker-log.repository.mongo.ts +++ b/server/src/domain/docker/docker-log.repository.mongo.ts @@ -91,3 +91,5 @@ class MongoDockerLogsRepository implements IDockerLogsRepository { deleteByMonitorIdsNotIn = async (monitorIds: string[]) => (await DockerLogModel.deleteMany({ "metadata.monitorId": { $nin: monitorIds.map((id) => new mongoose.Types.ObjectId(id)) } })).deletedCount ?? 0; } + +export default MongoDockerLogsRepository; diff --git a/server/src/domain/docker/docker-log.service.ts b/server/src/domain/docker/docker-log.service.ts index 0f914dde3..a67888de0 100644 --- a/server/src/domain/docker/docker-log.service.ts +++ b/server/src/domain/docker/docker-log.service.ts @@ -13,7 +13,7 @@ export interface IDockerLogsService { createDockerLogs(logs: DockerLog[]): Promise; } -export class DockerLogsSerivce implements IDockerLogsService { +export class DockerLogsService implements IDockerLogsService { static SERVICE_NAME = SERVICE_NAME; private logger: ILogger; diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index b4a7131df..bc4750de6 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -9,6 +9,7 @@ import type { GroupedGeoCheckResult, DockerDetailsResult, DockerContainerDetailsResult, + DockerContainerLogsResult, } from "@/domain/monitors/monitor.type.js"; import { supportsGeoCheck, supportsUptimeDetails } from "@/domain/monitors/monitor.type.js"; import type { @@ -31,6 +32,7 @@ import type { ImportedMonitor } from "@/api/validation/monitorValidation.js"; import { ILogger } from "@/utils/logger.js"; import { IJobScheduler } from "@/worker/worker.interface.js"; import { DateRange } from "@/types/query.js"; +import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js"; const SERVICE_NAME = "MonitorService"; @@ -73,6 +75,13 @@ export interface IMonitorService { containerName: string; dateRange: DateRange; }): Promise; + getDockerContainerLogs(args: { + teamId: string; + monitorId: string; + containerName: string; + before?: Date; + limit: number; + }): Promise; getGeoChecksByMonitorId(args: { teamId: string; monitorId: string; @@ -130,6 +139,7 @@ export class MonitorService implements IMonitorService { private monitorsRepository: IMonitorsRepository; private checksRepository: IChecksRepository; private geoChecksRepository: IGeoChecksRepository; + private dockerLogsRepository: IDockerLogsRepository; private monitorStatsRepository: IMonitorStatsRepository; private statusPagesRepository: IStatusPagesRepository; private incidentsRepository: IIncidentsRepository; @@ -141,6 +151,7 @@ export class MonitorService implements IMonitorService { monitorsRepository, checksRepository, geoChecksRepository, + dockerLogsRepository, monitorStatsRepository, statusPagesRepository, incidentsRepository, @@ -151,6 +162,7 @@ export class MonitorService implements IMonitorService { monitorsRepository: IMonitorsRepository; checksRepository: IChecksRepository; geoChecksRepository: IGeoChecksRepository; + dockerLogsRepository: IDockerLogsRepository; monitorStatsRepository: IMonitorStatsRepository; statusPagesRepository: IStatusPagesRepository; incidentsRepository: IIncidentsRepository; @@ -161,6 +173,7 @@ export class MonitorService implements IMonitorService { this.monitorsRepository = monitorsRepository; this.checksRepository = checksRepository; this.geoChecksRepository = geoChecksRepository; + this.dockerLogsRepository = dockerLogsRepository; this.monitorStatsRepository = monitorStatsRepository; this.statusPagesRepository = statusPagesRepository; this.incidentsRepository = incidentsRepository; @@ -385,6 +398,38 @@ export class MonitorService implements IMonitorService { }; }; + getDockerContainerLogs = async ({ + teamId, + monitorId, + containerName, + before, + limit, + }: { + teamId: string; + monitorId: string; + containerName: string; + before?: Date; + limit: number; + }): 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 logs`, + status: 400, + }); + } + + const logs = await this.dockerLogsRepository.findByContainerName({ monitorId, containerName, before, limit }); + return { logs, nextCursor: logs.length === limit ? (logs[logs.length - 1]?.checkedAt ?? null) : null }; + }; + getGeoChecksByMonitorId = async ({ teamId, monitorId, @@ -593,6 +638,14 @@ export class MonitorService implements IMonitorService { }); }); + await this.dockerLogsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => { + this.logger.warn({ + message: `Error deleting docker logs for monitor ${monitor.id} with name ${monitor.name}`, + service: SERVICE_NAME, + stack: err instanceof Error ? err.stack : undefined, + }); + }); + await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => { this.logger.warn({ message: `Error deleting monitor stats for monitor ${monitor.id} with name ${monitor.name}`, diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index df9581131..49fc3cf57 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -5,6 +5,7 @@ export type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js"; import http from "node:http"; import { HardwareStats } from "@/domain/checks/check.type.js"; import { MonitorStats } from "@/domain/monitor-stats/monitor-stats.type.js"; +import { DockerLogPage } from "@/domain/docker/docker-log.type.js"; export const HttpStatusCodes = [ ...Object.keys(http.STATUS_CODES).map(Number), @@ -189,3 +190,5 @@ export interface Game { export type GamesMap = Record; export type MonitorScheduleFields = Pick; + +export type DockerContainerLogsResult = DockerLogPage; diff --git a/server/src/service/bufferService.ts b/server/src/service/bufferService.ts index 2d10aebbb..672ec174c 100755 --- a/server/src/service/bufferService.ts +++ b/server/src/service/bufferService.ts @@ -5,14 +5,18 @@ import type { ILogger } from "@/utils/logger.js"; import type { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import type { IJobsRepository } from "@/domain/jobs/job.repository.interface.js"; import { ICheckService } from "../domain/checks/check.service.js"; +import { DockerLog } from "@/domain/docker/docker-log.type.js"; +import { IDockerLogsService } from "@/domain/docker/docker-log.service.js"; const SERVICE_NAME = "BufferService"; export interface IBufferService { addToBuffer(check: Check): void; addGeoCheckToBuffer(geoCheck: GeoCheck): void; + addDockerLogToBuffer(dockerLog: DockerLog): void; scheduleNextFlush(): void; flushBuffer(): Promise; flushGeoBuffer(): Promise; + flushDockerLogsBuffer(): Promise; shutdown(): Promise; } @@ -23,15 +27,18 @@ export class BufferService implements IBufferService { private SERVICE_NAME: string; private buffer: Check[]; private geoBuffer: GeoCheck[]; + private dockerLogBuffer: DockerLog[]; private bufferTimer: NodeJS.Timeout | null = null; private checksService: ICheckService; private geoChecksService: IGeoChecksService; + private dockerLogsService: IDockerLogsService; private jobsRepository: IJobsRepository; constructor( logger: ILogger, checkService: ICheckService, geoChecksService: IGeoChecksService, + dockerLogsService: IDockerLogsService, settingsService: ISettingsService, jobsRepository: IJobsRepository ) { @@ -39,10 +46,12 @@ export class BufferService implements IBufferService { this.logger = logger; this.checksService = checkService; this.geoChecksService = geoChecksService; + this.dockerLogsService = dockerLogsService; this.jobsRepository = jobsRepository; this.SERVICE_NAME = SERVICE_NAME; this.buffer = []; this.geoBuffer = []; + this.dockerLogBuffer = []; this.scheduleNextFlush(); this.logger.info({ message: `Buffer service initialized, flushing every ${this.BUFFER_TIMEOUT / 1000}s`, @@ -77,6 +86,19 @@ export class BufferService implements IBufferService { } } + addDockerLogToBuffer(dockerLog: DockerLog): void { + try { + this.dockerLogBuffer.push(dockerLog); + } catch (error: unknown) { + this.logger.error({ + message: error instanceof Error ? error.message : "Unknown error", + service: this.SERVICE_NAME, + method: "addDockerLogToBuffer", + stack: error instanceof Error ? error.stack : undefined, + }); + } + } + scheduleNextFlush() { if (this.bufferTimer) { clearTimeout(this.bufferTimer); @@ -85,6 +107,7 @@ export class BufferService implements IBufferService { try { await this.flushBuffer(); await this.flushGeoBuffer(); + await this.flushDockerLogsBuffer(); } catch (error: unknown) { this.logger.error({ message: error instanceof Error ? error.message : "Unknown error", @@ -150,6 +173,30 @@ export class BufferService implements IBufferService { } } + async flushDockerLogsBuffer() { + if (this.dockerLogBuffer.length === 0) { + return; + } + // Take the batch first so a write that drains it can't be appended to mid-flush + const batch = this.dockerLogBuffer; + this.dockerLogBuffer = []; + try { + this.logger.debug({ + message: `Flushing ${batch.length} docker logs to database`, + service: this.SERVICE_NAME, + method: "flushDockerLogsBuffer", + }); + await this.dockerLogsService.createDockerLogs(batch); + } catch (error: unknown) { + this.logger.error({ + message: error instanceof Error ? error.message : "Unknown error", + service: this.SERVICE_NAME, + method: "flushDockerLogsBuffer", + stack: error instanceof Error ? error.stack : undefined, + }); + } + } + async shutdown() { if (this.bufferTimer) { clearTimeout(this.bufferTimer); @@ -157,5 +204,6 @@ export class BufferService implements IBufferService { } await this.flushBuffer(); await this.flushGeoBuffer(); + await this.flushDockerLogsBuffer(); } } diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index 4e0d5e921..f2ece3f4e 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -252,7 +252,7 @@ export class DockerProvider implements IStatusProvider { const demuxedBuffer = this.demuxLogBuffer(buffer); for (const byteStream of demuxedBuffer) { const { stream, chunk } = byteStream; - for (const raw of chunk.split("/n")) { + for (const raw of chunk.split("\n")) { if (raw.length === 0) continue; const line = this.toLogLine(stream, raw); if (line) lines.push(line); diff --git a/server/src/worker/worker.check-producer.ts b/server/src/worker/worker.check-producer.ts index f297d01be..404978aef 100644 --- a/server/src/worker/worker.check-producer.ts +++ b/server/src/worker/worker.check-producer.ts @@ -1,6 +1,6 @@ import { Monitor } from "@/domain/monitors/monitor.type.js"; import { Check } from "@/domain/checks/check.type.js"; -import { MonitorStatusResponse } from "@/types/network.js"; +import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js"; import { AppError } from "@/utils/AppError.js"; import { ILogger } from "@/utils/logger.js"; import { IBufferService } from "@/service/bufferService.js"; @@ -10,6 +10,7 @@ import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interf import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js"; import { isWindowActive } from "@/utils/maintenanceWindow.js"; import { IProxyResolver } from "@/service/network/ProxyResolver.js"; +import { IDockerLogsService } from "@/domain/docker/docker-log.service.js"; export interface ICheckProducer { produce(monitor: Monitor): Promise<{ status: MonitorStatusResponse; check: Check } | null>; @@ -25,6 +26,7 @@ export class CheckProducer implements ICheckProducer { private networkService: INetworkService, private proxyResolver: IProxyResolver, private bufferService: IBufferService, + private dockerLogsService: IDockerLogsService, private logger: ILogger ) {} @@ -81,6 +83,14 @@ export class CheckProducer implements ICheckProducer { } // Step 2b: Add to buffer this.bufferService.addToBuffer(check); + + // Step 2c: Handle docker logs + if (status.type === "docker") { + const dockerLogs = await this.dockerLogsService.buildDockerLogs(status as MonitorStatusResponse); + for (const dockerLog of dockerLogs) { + this.bufferService.addDockerLogToBuffer(dockerLog); + } + } return { status, check }; }; } diff --git a/server/src/worker/worker.helper.ts b/server/src/worker/worker.helper.ts index 9f044763d..d8a35e0b2 100644 --- a/server/src/worker/worker.helper.ts +++ b/server/src/worker/worker.helper.ts @@ -10,6 +10,7 @@ import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interf import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js"; import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js"; import { ILogger } from "@/utils/logger.js"; +import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js"; export interface IWorkerHelper { getCleanupOrphanedJob(): () => Promise; @@ -43,6 +44,7 @@ export class WorkerHelper implements IWorkerHelper { private checksRepository: IChecksRepository; private incidentsRepository: IIncidentsRepository; private geoChecksRepository: IGeoChecksRepository; + private dockerLogsRepository: IDockerLogsRepository; constructor( logger: ILogger, @@ -54,7 +56,8 @@ export class WorkerHelper implements IWorkerHelper { monitorStatsRepository: IMonitorStatsRepository, checksRepository: IChecksRepository, incidentsRepository: IIncidentsRepository, - geoChecksRepository: IGeoChecksRepository + geoChecksRepository: IGeoChecksRepository, + dockerLogsRepository: IDockerLogsRepository ) { this.logger = logger; this.checkService = checkService; @@ -66,6 +69,7 @@ export class WorkerHelper implements IWorkerHelper { this.checksRepository = checksRepository; this.incidentsRepository = incidentsRepository; this.geoChecksRepository = geoChecksRepository; + this.dockerLogsRepository = dockerLogsRepository; } getCleanupOrphanedJob = () => { @@ -142,6 +146,15 @@ export class WorkerHelper implements IWorkerHelper { }); } + const deletedDockerLogsCount = await this.dockerLogsRepository.deleteByMonitorIdsNotIn(allMonitorIds); + if (deletedDockerLogsCount > 0) { + this.logger.info({ + message: `Deleted ${deletedDockerLogsCount} orphaned docker logs`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + // Remove orphaned jobs const deletedJobsCount = await this.jobsRepository.deleteByMonitorIdsNotIn(allMonitorIds); if (deletedJobsCount > 0) { From f7e1793bac3b1eeead5a60cfe29ef7d78c7a97aa Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 3 Sep 2026 12:56:44 -0700 Subject: [PATCH 07/23] update tests --- server/openapi/routes/monitor.ts | 12 ++ .../providers/network/dockerProvider.test.ts | 75 +++++++++++- .../test/unit/services/bufferService.test.ts | 113 ++++++++++++++++-- .../test/unit/services/checkProducer.test.ts | 26 ++++ .../unit/services/dockerLogsService.test.ts | 87 ++++++++++++++ .../test/unit/services/monitorService.test.ts | 65 ++++++++++ .../test/unit/services/workerHelper.test.ts | 10 +- .../unit/validation/monitorValidation.test.ts | 15 +++ 8 files changed, 392 insertions(+), 11 deletions(-) create mode 100644 server/test/unit/services/dockerLogsService.test.ts diff --git a/server/openapi/routes/monitor.ts b/server/openapi/routes/monitor.ts index f7d92c94e..533f9f9c3 100644 --- a/server/openapi/routes/monitor.ts +++ b/server/openapi/routes/monitor.ts @@ -24,6 +24,8 @@ import { dockerContainerDetailsResponseSchema, monitorResponseSchema, uptimeDetailsResponseSchema, + getDockerContainerLogsQueryValidation, + dockerContainerLogsResponseSchema, } from "@/api/validation/monitorValidation.js"; import { updateNotificationsValidation } from "@/api/validation/notificationValidation.js"; @@ -131,6 +133,16 @@ registry.registerPath({ responses: { "200": okJson(dockerContainerDetailsResponseSchema), ...standardErrors }, }); +registry.registerPath({ + method: "get", + path: "/monitors/docker/details/{monitorId}/containers/{containerName}/logs", + tags, + summary: "Get stored log lines for one container on a Docker host monitor, newest first", + security: bearer, + request: { params: getDockerContainerNameParamValidation, query: getDockerContainerLogsQueryValidation }, + responses: { "200": okJson(dockerContainerLogsResponseSchema), ...standardErrors }, +}); + registry.registerPath({ method: "get", path: "/monitors/pagespeed/details/{monitorId}", diff --git a/server/test/unit/providers/network/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts index 4a9e3fe52..df202e3fd 100644 --- a/server/test/unit/providers/network/dockerProvider.test.ts +++ b/server/test/unit/providers/network/dockerProvider.test.ts @@ -5,6 +5,7 @@ import { createMockLogger } from "../../../helpers/createMockLogger.ts"; import { NETWORK_ERROR } from "../../../../src/types/network.ts"; import { AppError } from "../../../../src/utils/AppError.ts"; import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts"; +import { DOCKER_LOG_TAIL_LINES } from "../../../../src/domain/docker/docker.type.ts"; // ── Fixtures ───────────────────────────────────────────────────────────────── @@ -53,10 +54,19 @@ const makeStats = (overrides?: Record) => ({ ...overrides, }); -const setup = (overrides: Partial<{ ping: any; listContainers: any; getContainer: any; inspect: any; stats: any }> = {}) => { +const makeLogFrame = (streamType: number, text: string): Buffer => { + const payload = Buffer.from(text); + const header = Buffer.alloc(8); + header.writeUInt8(streamType, 0); + header.writeUInt32BE(payload.length, 4); + return Buffer.concat([header, payload]); +}; + +const setup = (overrides: Partial<{ ping: any; listContainers: any; getContainer: any; inspect: any; stats: any; logs: any }> = {}) => { const inspect = overrides.inspect ?? jest.fn().mockResolvedValue(makeInspect()); const stats = overrides.stats ?? jest.fn().mockResolvedValue(makeStats()); - const getContainer = overrides.getContainer ?? jest.fn().mockReturnValue({ inspect, stats }); + const logs = overrides.logs ?? jest.fn().mockResolvedValue(Buffer.alloc(0)); + const getContainer = overrides.getContainer ?? jest.fn().mockReturnValue({ inspect, stats, logs }); const instance = { ping: overrides.ping ?? jest.fn().mockResolvedValue("OK"), listContainers: overrides.listContainers ?? jest.fn().mockResolvedValue([makeContainer()]), @@ -65,7 +75,7 @@ const setup = (overrides: Partial<{ ping: any; listContainers: any; getContainer const DockerLib = jest.fn().mockReturnValue(instance) as any; const logger = createMockLogger(); const provider = new DockerProvider(logger as any, DockerLib); - return { provider, logger, DockerLib, ping: instance.ping, listContainers: instance.listContainers, getContainer, inspect, stats }; + return { provider, logger, DockerLib, ping: instance.ping, listContainers: instance.listContainers, getContainer, inspect, stats, logs }; }; // ── Contract ───────────────────────────────────────────────────────────────── @@ -278,6 +288,65 @@ describe("DockerProvider", () => { }); }); + describe("container logs", () => { + it("parses multiplexed stdout and stderr with normalized timestamps", async () => { + const logs = jest + .fn() + .mockResolvedValue( + Buffer.concat([makeLogFrame(1, "2026-01-01T00:00:37.1Z ready\n"), makeLogFrame(2, "2026-01-01T00:00:38.123Z failed\r\n")]) + ); + const { provider } = setup({ logs }); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload?.logs?.[0]?.lines).toEqual([ + { ts: "2026-01-01T00:00:37.100000000Z", stream: "stdout", text: "ready" }, + { ts: "2026-01-01T00:00:38.123000000Z", stream: "stderr", text: "failed" }, + ]); + }); + + it("treats raw TTY output as stdout and drops lines without timestamps", async () => { + const logs = jest.fn().mockResolvedValue(Buffer.from("noise\n2026-01-01T00:00:00Z first\n2026-01-01T00:00:01.12Z second\n")); + const { provider } = setup({ logs }); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload?.logs?.[0]?.lines).toEqual([ + { ts: "2026-01-01T00:00:00.000000000Z", stream: "stdout", text: "first" }, + { ts: "2026-01-01T00:00:01.120000000Z", stream: "stdout", text: "second" }, + ]); + }); + + it("truncates oversized UTF-8 lines and appends the marker", async () => { + const logs = jest.fn().mockResolvedValue(Buffer.from(`2026-01-01T00:00:00Z ${"x".repeat(5000)}\n`)); + const { provider } = setup({ logs }); + + const result = await provider.handle(makeMonitor()); + const text = result.payload?.logs?.[0]?.lines[0]?.text; + expect(text).toEqual(expect.stringMatching(/ …\[truncated\]$/)); + expect(Buffer.byteLength(text?.replace(" …[truncated]", "") ?? "", "utf8")).toBeLessThanOrEqual(4096); + }); + + it("isolates a logs failure and warns", async () => { + const { provider, logger } = setup({ logs: jest.fn().mockRejectedValue(new Error("logs failed")) }); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload?.logs).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to read logs for container my-container" })); + }); + + it("requests a bounded timestamped tail for every container state", async () => { + const containers = [makeContainer({ Id: "a".repeat(64) }), makeContainer({ Id: "b".repeat(64), State: "exited" })]; + const { provider, logs } = setup({ listContainers: jest.fn().mockResolvedValue(containers) }); + + await provider.handle(makeMonitor()); + + expect(logs).toHaveBeenCalledTimes(2); + expect(logs).toHaveBeenCalledWith({ follow: false, stdout: true, stderr: true, timestamps: true, tail: DOCKER_LOG_TAIL_LINES }); + }); + }); + // ── Enrichment fault isolation ─────────────────────────────────────── describe("enrichment fault isolation", () => { diff --git a/server/test/unit/services/bufferService.test.ts b/server/test/unit/services/bufferService.test.ts index 90b5489bf..d193c7b3d 100644 --- a/server/test/unit/services/bufferService.test.ts +++ b/server/test/unit/services/bufferService.test.ts @@ -7,6 +7,8 @@ import type { ISettingsService } from "../../../src/domain/app-settings/app-sett import type { IJobsRepository } from "../../../src/domain/jobs/job.repository.interface.ts"; import type { Check } from "../../../src/domain/checks/check.type.ts"; import type { GeoCheck } from "../../../src/domain/geo-checks/geo-check.type.ts"; +import type { IDockerLogsService } from "../../../src/domain/docker/docker-log.service.ts"; +import type { DockerLog } from "../../../src/domain/docker/docker-log.type.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -20,6 +22,11 @@ const createMockGeoChecksService = () => createGeoChecks: jest.fn().mockResolvedValue([]), }) as unknown as jest.Mocked; +const createMockDockerLogsService = () => + ({ + createDockerLogs: jest.fn().mockResolvedValue(0), + }) as unknown as jest.Mocked; + const createMockSettingsService = (nodeEnv: string = "development") => ({ getSettings: jest.fn().mockReturnValue({ nodeEnv }), @@ -48,14 +55,28 @@ const makeGeoCheck = (overrides?: Partial): GeoCheck => ...overrides, }) as GeoCheck; +const makeDockerLog = (overrides?: Partial): DockerLog => + ({ + id: "docker-log-1", + metadata: { monitorId: "mon-1", teamId: "team-1", containerId: "container-1", containerName: "web" }, + lines: [{ ts: "2026-01-01T00:00:00.000000000Z", stream: "stdout", text: "ready" }], + gap: false, + checkedAt: "2026-01-01T00:00:01.000Z", + expiry: "2026-01-08T00:00:01.000Z", + createdAt: "2026-01-01T00:00:01.000Z", + updatedAt: "2026-01-01T00:00:01.000Z", + ...overrides, + }) as DockerLog; + const createService = (nodeEnv: string = "development") => { const logger = createMockLogger(); const checkService = createMockCheckService(); const geoChecksService = createMockGeoChecksService(); + const dockerLogsService = createMockDockerLogsService(); const settingsService = createMockSettingsService(nodeEnv); const jobsRepository = createMockJobsRepository(); - const service = new BufferService(logger as any, checkService, geoChecksService, settingsService, jobsRepository); - return { service, logger, checkService, geoChecksService, jobsRepository }; + const service = new BufferService(logger as any, checkService, geoChecksService, dockerLogsService, settingsService, jobsRepository); + return { service, logger, checkService, geoChecksService, dockerLogsService, jobsRepository }; }; // ── Tests ──────────────────────────────────────────────────────────────────── @@ -209,6 +230,27 @@ describe("BufferService", () => { }); }); + describe("addDockerLogToBuffer", () => { + it("adds a docker log to the buffer", async () => { + const { service, dockerLogsService } = createService(); + const dockerLog = makeDockerLog(); + + service.addDockerLogToBuffer(dockerLog); + await service.flushDockerLogsBuffer(); + + expect(dockerLogsService.createDockerLogs).toHaveBeenCalledWith([dockerLog]); + }); + + it("logs an error if push throws", () => { + const { service, logger } = createService(); + Object.defineProperty(service, "dockerLogBuffer", { value: Object.freeze([]) }); + + service.addDockerLogToBuffer(makeDockerLog()); + + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ method: "addDockerLogToBuffer" })); + }); + }); + // ── scheduleNextFlush ──────────────────────────────────────────────── describe("scheduleNextFlush", () => { @@ -223,15 +265,17 @@ describe("BufferService", () => { expect(jest.getTimerCount()).toBe(initialTimerCount); }); - it("flushes buffer and geo buffer when timer fires", async () => { - const { service, checkService, geoChecksService } = createService(); + it("flushes all buffers when timer fires", async () => { + const { service, checkService, geoChecksService, dockerLogsService } = createService(); service.addToBuffer(makeCheck()); service.addGeoCheckToBuffer(makeGeoCheck()); + service.addDockerLogToBuffer(makeDockerLog()); await jest.advanceTimersByTimeAsync(1000); expect(checkService.createChecks).toHaveBeenCalled(); expect(geoChecksService.createGeoChecks).toHaveBeenCalled(); + expect(dockerLogsService.createDockerLogs).toHaveBeenCalled(); }); it("reschedules after flush completes", async () => { @@ -472,18 +516,72 @@ describe("BufferService", () => { }); }); + describe("flushDockerLogsBuffer", () => { + it("does nothing when the docker log buffer is empty", async () => { + const { service, dockerLogsService } = createService(); + + await service.flushDockerLogsBuffer(); + + expect(dockerLogsService.createDockerLogs).not.toHaveBeenCalled(); + }); + + it("flushes the batch and clears it", async () => { + const { service, dockerLogsService } = createService(); + const dockerLog = makeDockerLog(); + service.addDockerLogToBuffer(dockerLog); + + await service.flushDockerLogsBuffer(); + await service.flushDockerLogsBuffer(); + + expect(dockerLogsService.createDockerLogs).toHaveBeenCalledWith([dockerLog]); + expect(dockerLogsService.createDockerLogs).toHaveBeenCalledTimes(1); + }); + + it("does not drop logs added while a flush is in flight", async () => { + const { service, dockerLogsService } = createService(); + let resolveWrite!: (value: number) => void; + (dockerLogsService.createDockerLogs as jest.Mock).mockImplementationOnce(() => new Promise((resolve) => (resolveWrite = resolve))); + const first = makeDockerLog({ id: "docker-log-1" }); + const second = makeDockerLog({ id: "docker-log-2" }); + service.addDockerLogToBuffer(first); + + const flush = service.flushDockerLogsBuffer(); + service.addDockerLogToBuffer(second); + resolveWrite(1); + await flush; + await service.flushDockerLogsBuffer(); + + expect(dockerLogsService.createDockerLogs).toHaveBeenNthCalledWith(1, [first]); + expect(dockerLogsService.createDockerLogs).toHaveBeenNthCalledWith(2, [second]); + }); + + it("drops a failed batch and logs the error", async () => { + const { service, dockerLogsService, logger } = createService(); + (dockerLogsService.createDockerLogs as jest.Mock).mockRejectedValueOnce(new Error("DB error")); + service.addDockerLogToBuffer(makeDockerLog()); + + await service.flushDockerLogsBuffer(); + await service.flushDockerLogsBuffer(); + + expect(dockerLogsService.createDockerLogs).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "DB error", method: "flushDockerLogsBuffer" })); + }); + }); + // ── shutdown ───────────────────────────────────────────────────────────── describe("shutdown", () => { - it("stops the flush timer and flushes both buffers", async () => { - const { service, checkService, geoChecksService } = createService(); + it("stops the flush timer and flushes all buffers", async () => { + const { service, checkService, geoChecksService, dockerLogsService } = createService(); service.addToBuffer(makeCheck()); service.addGeoCheckToBuffer(makeGeoCheck()); + service.addDockerLogToBuffer(makeDockerLog()); await service.shutdown(); expect(checkService.createChecks).toHaveBeenCalledTimes(1); expect(geoChecksService.createGeoChecks).toHaveBeenCalledTimes(1); + expect(dockerLogsService.createDockerLogs).toHaveBeenCalledTimes(1); // Timer is cleared: advancing past the flush interval triggers no further flush. await jest.advanceTimersByTimeAsync(60 * 1000); @@ -491,12 +589,13 @@ describe("BufferService", () => { }); it("is safe to call with empty buffers", async () => { - const { service, checkService, geoChecksService } = createService(); + const { service, checkService, geoChecksService, dockerLogsService } = createService(); await service.shutdown(); expect(checkService.createChecks).not.toHaveBeenCalled(); expect(geoChecksService.createGeoChecks).not.toHaveBeenCalled(); + expect(dockerLogsService.createDockerLogs).not.toHaveBeenCalled(); }); }); }); diff --git a/server/test/unit/services/checkProducer.test.ts b/server/test/unit/services/checkProducer.test.ts index 2a1aa29f1..7116c5adb 100644 --- a/server/test/unit/services/checkProducer.test.ts +++ b/server/test/unit/services/checkProducer.test.ts @@ -28,6 +28,7 @@ const createProducer = (overrides?: Record) => { networkService: { requestStatus: jest.fn().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) }, proxyResolver: { resolve: jest.fn().mockResolvedValue(undefined) }, buffer: { addToBuffer: jest.fn() }, + dockerLogsService: { buildDockerLogs: jest.fn().mockResolvedValue([]) }, ...overrides, }; const producer = new CheckProducer( @@ -37,6 +38,7 @@ const createProducer = (overrides?: Record) => { defaults.networkService as any, defaults.proxyResolver as any, defaults.buffer as any, + defaults.dockerLogsService as any, defaults.logger as any ); return { producer, defaults }; @@ -142,4 +144,28 @@ describe("CheckProducer", () => { expect(defaults.buffer.addToBuffer).toHaveBeenCalledWith(check); expect(result).toEqual({ status, check }); }); + + it("builds and buffers docker logs for a docker status", async () => { + const status = { type: "docker", monitorId: "m1", teamId: "team", status: true, code: 200, message: "OK", payload: {} }; + const dockerLogs = [{ id: "docker-log-1" }, { id: "docker-log-2" }]; + const { producer, defaults } = createProducer({ + networkService: { requestStatus: jest.fn().mockResolvedValue(status) }, + buffer: { addToBuffer: jest.fn(), addDockerLogToBuffer: jest.fn() }, + dockerLogsService: { buildDockerLogs: jest.fn().mockResolvedValue(dockerLogs) }, + }); + + await producer.produce(makeMonitor({ type: "docker" })); + + expect(defaults.dockerLogsService.buildDockerLogs).toHaveBeenCalledWith(status); + expect(defaults.buffer.addDockerLogToBuffer).toHaveBeenNthCalledWith(1, dockerLogs[0]); + expect(defaults.buffer.addDockerLogToBuffer).toHaveBeenNthCalledWith(2, dockerLogs[1]); + }); + + it("does not build docker logs for a non-docker status", async () => { + const { producer, defaults } = createProducer(); + + await producer.produce(makeMonitor()); + + expect(defaults.dockerLogsService.buildDockerLogs).not.toHaveBeenCalled(); + }); }); diff --git a/server/test/unit/services/dockerLogsService.test.ts b/server/test/unit/services/dockerLogsService.test.ts new file mode 100644 index 000000000..9e27a84e1 --- /dev/null +++ b/server/test/unit/services/dockerLogsService.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { DockerLogsService } from "../../../src/domain/docker/docker-log.service.ts"; +import { DOCKER_LOG_TAIL_LINES, type DockerLogLine } from "../../../src/domain/docker/docker.type.ts"; +import type { MonitorStatusResponse, DockerStatusPayload } from "../../../src/types/network.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +const line = (second: number): DockerLogLine => ({ + ts: `2026-01-01T00:00:${String(second).padStart(2, "0")}.000000000Z`, + stream: "stdout", + text: `line ${second}`, +}); + +const status = (logs?: DockerStatusPayload["logs"]): MonitorStatusResponse => ({ + monitorId: "monitor-1", + teamId: "team-1", + type: "docker", + status: true, + code: 200, + message: "OK", + payload: { containers: [], summary: { total: 0, running: 0, stopped: 0, unhealthy: 0 }, logs }, +}); + +const createService = () => { + const repository = { + findLastLineTimestamp: jest.fn().mockResolvedValue(null), + createDockerLogs: jest.fn().mockResolvedValue(0), + }; + const logger = createMockLogger(); + return { service: new DockerLogsService({ logger: logger as any, dockerLogsRepository: repository as any }), repository, logger }; +}; + +describe("DockerLogsService", () => { + it("returns no documents for missing or empty logs", async () => { + const { service, repository } = createService(); + expect(await service.buildDockerLogs(status())).toEqual([]); + expect(await service.buildDockerLogs(status([]))).toEqual([]); + expect(repository.findLastLineTimestamp).not.toHaveBeenCalled(); + }); + + it("seeds the cursor, drops old lines, and sets seven-day expiry", async () => { + const { service, repository } = createService(); + repository.findLastLineTimestamp.mockResolvedValue(line(1).ts); + + const result = await service.buildDockerLogs(status([{ containerId: "container-1", containerName: "web", lines: [line(1), line(2)] }])); + + expect(repository.findLastLineTimestamp).toHaveBeenCalledWith("monitor-1", "container-1"); + expect(result[0]?.lines).toEqual([line(2)]); + expect(new Date(result[0]!.expiry).getTime() - new Date(result[0]!.checkedAt).getTime()).toBe(7 * 24 * 60 * 60 * 1000); + }); + + it("advances the cursor in memory and does not query again", async () => { + const { service, repository } = createService(); + const logs = [{ containerId: "container-1", containerName: "web", lines: [line(1)] }]; + + expect(await service.buildDockerLogs(status(logs))).toHaveLength(1); + expect(await service.buildDockerLogs(status(logs))).toEqual([]); + expect(repository.findLastLineTimestamp).toHaveBeenCalledTimes(1); + }); + + it("sets gap only when every tail line is fresh", async () => { + const { service } = createService(); + const lines = Array.from({ length: DOCKER_LOG_TAIL_LINES }, (_, index) => ({ + ts: `2026-01-01T00:00:00.${String(index).padStart(9, "0")}Z`, + stream: "stdout" as const, + text: String(index), + })); + + const [result] = await service.buildDockerLogs(status([{ containerId: "container-1", containerName: "web", lines }])); + expect(result?.gap).toBe(true); + }); + + it("skips a failed container and continues with the others", async () => { + const { service, repository, logger } = createService(); + repository.findLastLineTimestamp.mockRejectedValueOnce(new Error("DB down")).mockResolvedValueOnce(null); + + const result = await service.buildDockerLogs( + status([ + { containerId: "bad", containerName: "bad-container", lines: [line(1)] }, + { containerId: "good", containerName: "good-container", lines: [line(2)] }, + ]) + ); + + expect(result).toHaveLength(1); + expect(result[0]?.metadata.containerId).toBe("good"); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build docker logs for container bad-container" })); + }); +}); diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index 79753e41c..f4ed45e5a 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals"; import { MonitorService } from "../../../src/domain/monitors/monitor.service.ts"; import type { IChecksRepository } from "../../../src/domain/checks/check.repository.interface.ts"; import type { IGeoChecksRepository } from "../../../src/domain/geo-checks/geo-check.repository.interface.ts"; +import type { IDockerLogsRepository } from "../../../src/domain/docker/docker-log.repository.interface.ts"; import type { IIncidentsRepository } from "../../../src/domain/incidents/incident.repository.interface.ts"; import type { IMonitorStatsRepository } from "../../../src/domain/monitor-stats/monitor-stats.repository.interface.ts"; import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts"; @@ -50,6 +51,12 @@ const createGeoChecksRepositoryMock = () => deleteByMonitorId: jest.fn(), }) as unknown as IGeoChecksRepository; +const createDockerLogsRepositoryMock = () => + ({ + findByContainerName: jest.fn(), + deleteByMonitorId: jest.fn().mockResolvedValue(0), + }) as unknown as IDockerLogsRepository; + const createIncidentsRepositoryMock = () => ({ deleteByMonitorId: jest.fn(), @@ -89,6 +96,7 @@ const createService = ( monitorStatsRepository?: IMonitorStatsRepository; statusPagesRepository?: IStatusPagesRepository; geoChecksRepository?: IGeoChecksRepository; + dockerLogsRepository?: IDockerLogsRepository; incidentsRepository?: IIncidentsRepository; jobQueue?: ReturnType; logger?: ReturnType; @@ -104,6 +112,7 @@ const createService = ( monitorsRepository: overrides.monitorsRepository ?? createMonitorsRepositoryMock(), checksRepository: overrides.checksRepository ?? createChecksRepositoryMock(), geoChecksRepository: overrides.geoChecksRepository ?? createGeoChecksRepositoryMock(), + dockerLogsRepository: overrides.dockerLogsRepository ?? createDockerLogsRepositoryMock(), monitorStatsRepository: overrides.monitorStatsRepository ?? createMonitorStatsRepositoryMock(), statusPagesRepository: overrides.statusPagesRepository ?? createStatusPagesRepositoryMock(), incidentsRepository: overrides.incidentsRepository ?? createIncidentsRepositoryMock(), @@ -594,6 +603,61 @@ describe("MonitorService", () => { }); }); + describe("getDockerContainerLogs", () => { + it("throws 404 when the monitor is not found", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(null); + const { service } = createService({ monitorsRepository }); + + await expect(service.getDockerContainerLogs({ teamId: TEAM_ID, monitorId: "missing", containerName: "web", limit: 20 })).rejects.toThrow( + "Monitor with ID missing not found" + ); + }); + + it("throws 400 for a non-docker monitor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "http" })); + const { service } = createService({ monitorsRepository }); + + await expect(service.getDockerContainerLogs({ teamId: TEAM_ID, monitorId: MONITOR_ID, containerName: "web", limit: 20 })).rejects.toThrow( + "monitors are not supported for docker container logs" + ); + }); + + it("passes pagination through and returns the last checkedAt as a full-page cursor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const dockerLogsRepository = createDockerLogsRepositoryMock(); + const before = new Date("2026-01-02T00:00:00.000Z"); + const logs = [{ checkedAt: "2026-01-01T02:00:00.000Z" }, { checkedAt: "2026-01-01T01:00:00.000Z" }]; + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + (dockerLogsRepository.findByContainerName as jest.Mock).mockResolvedValue(logs); + const { service } = createService({ monitorsRepository, dockerLogsRepository }); + + const result = await service.getDockerContainerLogs({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + containerName: "web", + before, + limit: 2, + }); + + expect(dockerLogsRepository.findByContainerName).toHaveBeenCalledWith({ monitorId: MONITOR_ID, containerName: "web", before, limit: 2 }); + expect(result.nextCursor).toBe("2026-01-01T01:00:00.000Z"); + }); + + it("returns a null cursor for a short page", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const dockerLogsRepository = createDockerLogsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + (dockerLogsRepository.findByContainerName as jest.Mock).mockResolvedValue([{ checkedAt: "2026-01-01T01:00:00.000Z" }]); + const { service } = createService({ monitorsRepository, dockerLogsRepository }); + + const result = await service.getDockerContainerLogs({ teamId: TEAM_ID, monitorId: MONITOR_ID, containerName: "web", limit: 2 }); + + expect(result.nextCursor).toBeNull(); + }); + }); + describe("getPageSpeedDetailsById", () => { it("returns pagespeed details for a pagespeed monitor", async () => { const monitorsRepository = createMonitorsRepositoryMock(); @@ -1802,6 +1866,7 @@ describe("MonitorService", () => { monitorsRepository, checksRepository, geoChecksRepository, + dockerLogsRepository: createDockerLogsRepositoryMock(), monitorStatsRepository, statusPagesRepository, incidentsRepository, diff --git a/server/test/unit/services/workerHelper.test.ts b/server/test/unit/services/workerHelper.test.ts index fe7dca42d..749804768 100644 --- a/server/test/unit/services/workerHelper.test.ts +++ b/server/test/unit/services/workerHelper.test.ts @@ -32,6 +32,9 @@ const createHelper = (overrides?: Record) => { const geoChecksRepository = { deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), }; + const dockerLogsRepository = { + deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), + }; const settingsService = { getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 30 }), }; @@ -50,6 +53,7 @@ const createHelper = (overrides?: Record) => { checksRepository, incidentsRepository, geoChecksRepository, + dockerLogsRepository, ...overrides, }; @@ -63,7 +67,8 @@ const createHelper = (overrides?: Record) => { defaults.monitorStatsRepository as any, defaults.checksRepository as any, defaults.incidentsRepository as any, - defaults.geoChecksRepository as any + defaults.geoChecksRepository as any, + defaults.dockerLogsRepository as any ); return { helper, defaults }; }; @@ -81,6 +86,7 @@ describe("WorkerHelper", () => { (defaults.checksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(4); (defaults.incidentsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(1); (defaults.geoChecksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(5); + (defaults.dockerLogsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(7); (defaults.jobsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(6); const job = helper.getCleanupOrphanedJob(); @@ -93,12 +99,14 @@ describe("WorkerHelper", () => { expect(defaults.checksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); expect(defaults.incidentsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); expect(defaults.geoChecksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); + expect(defaults.dockerLogsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); expect(defaults.jobsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("2 orphaned monitors") })); expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("3 orphaned monitor stats") })); expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("4 orphaned checks") })); expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 orphaned incidents") })); expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("5 orphaned geo checks") })); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("7 orphaned docker logs") })); expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("6 orphaned jobs") })); }); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index 2b6f994d7..a2ae3f0f5 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -3,8 +3,23 @@ import { createMonitorBodyValidation, editMonitorBodyValidation, importMonitorsBodyValidation, + getDockerContainerLogsQueryValidation, } from "../../../src/api/validation/monitorValidation.ts"; +describe("getDockerContainerLogsQueryValidation", () => { + it("defaults limit to 20", () => { + expect(getDockerContainerLogsQueryValidation.parse({})).toEqual({ limit: 20 }); + }); + + it("rejects a limit above 50", () => { + expect(() => getDockerContainerLogsQueryValidation.parse({ limit: 51 })).toThrow(); + }); + + it("rejects a non-ISO before cursor", () => { + expect(() => getDockerContainerLogsQueryValidation.parse({ before: "yesterday" })).toThrow(); + }); +}); + const baseDnsBody = { name: "DNS check", type: "dns" as const, From bee3d4221e74e8c6532cb60c237a73c9f6e6442d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 3 Sep 2026 12:57:06 -0700 Subject: [PATCH 08/23] implement display of log head in logs tab --- .../Pages/Docker/ContainerDetails/TabLogs.tsx | 132 +++++++++++++++++- .../Pages/Docker/ContainerDetails/index.tsx | 7 +- client/src/Types/Check.ts | 25 ++++ client/src/Types/Monitor.ts | 5 + client/src/Utils/MonitorUtils.ts | 43 +++++- 5 files changed, 205 insertions(+), 7 deletions(-) diff --git a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx index 9dcea4842..9bd03e9bc 100644 --- a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx +++ b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx @@ -1,3 +1,131 @@ -export const TabLogs = () => { - return "This is the logs tab"; +// Components +import Stack from "@mui/material/Stack"; +import { BaseBox } from "@/Components/design-elements"; +import Typography from "@mui/material/Typography"; + +// Types +import type { DockerContainerLogsResponse } from "@/Types/Monitor"; + +// Hooks +import { useGet } from "@/Hooks/UseApi"; +import { useState, useEffect, useMemo } from "react"; +import type { DockerLog } from "@/Types/Check"; +import { useSelector } from "react-redux"; + +// Util +import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; +import { flattenDockerLogs } from "@/Utils/MonitorUtils"; +import { Box } from "@mui/material"; +import { formatDateWithTz } from "@/Utils/TimeUtils"; +import type { RootState } from "@/store"; + +interface TabLogsProps { + monitorId?: string; + containerName?: string; +} + +export const TabLogs = ({ monitorId, containerName }: TabLogsProps) => { + // State + const [olderLogs, setOlderLogs] = useState([]); + const [olderCursor, setOlderCursor] = useState(undefined); // undefined -> no older requested, string -> cursor, null -> no more data + const [loadingOlder, setLoadingOlder] = useState(false); + const [odlerError, setOlderError] = useState(null); + + const baseLogsUrl = + monitorId && containerName + ? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs` + : null; + + const { + data: head, + isLoading, + isValidating, + error: headError, + refetch, + } = useGet( + baseLogsUrl, + {}, + { + refreshInterval: 10_000, + keepPreviousData: true, + revalidateOnFocus: true, + } + ); + + const rows = useMemo(() => flattenDockerLogs(head?.logs ?? []), [head?.logs]); + const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + return ( + + + {`${rows.length} recent lines`} + {isValidating && "Refreshing..."} + + + {[...rows].reverse().map((row) => { + return ( + + + + {formatDateWithTz(row.ts, "MMM D HH:mm:ss", uiTimezone)} + + + + {row.stream} + + + + {row.text} + + + {row.gapBefore && ( + + + + {"Some lines were skipped between checks"} + + + + )} + + ); + })} + + + ); }; diff --git a/client/src/Pages/Docker/ContainerDetails/index.tsx b/client/src/Pages/Docker/ContainerDetails/index.tsx index b4583e4c1..51f765785 100644 --- a/client/src/Pages/Docker/ContainerDetails/index.tsx +++ b/client/src/Pages/Docker/ContainerDetails/index.tsx @@ -70,7 +70,12 @@ const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) = {activeTab === 0 && } - {activeTab === 1 && } + {activeTab === 1 && ( + + )} ); }; diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index 419ff7736..1585b7771 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -161,6 +161,31 @@ export interface DockerContainerMount { rw: boolean; } +export const DockerLogStreams = ["stdout", "stderr"] as const; +export type DockerLogStream = (typeof DockerLogStreams)[number]; + +export interface DockerLogLine { + ts: string; + stream: DockerLogStream; + text: string; +} + +export interface DockerLog { + id: string; + metadata: { + monitorId: string; + teamId: string; + containerId: string; + containerName: string; + }; + lines: DockerLogLine[]; + gap: boolean; + checkedAt: string; + expiry: string; + createdAt: string; + updatedAt: string; +} + export interface DockerContainerInfo { id: string; name: string; diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index a0ed555d4..300bd2ce4 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -5,6 +5,7 @@ import type { DailyCheckBucket, DockerContainerInfo, DockerContainerSummary, + DockerLog, } from "@/Types/Check"; import type { PageSpeedGroupedCheck } from "@/Types/Check"; import type { GeoContinent } from "@/Types/GeoCheck"; @@ -322,6 +323,10 @@ export interface DockerContainerResponse { stats: DockerContainerStats; } +export interface DockerContainerLogsResponse { + logs: DockerLog[]; + nextCursor: string | null; +} export interface Game { name: string; options?: { diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index 3a7778b75..cdc85ac84 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -1,10 +1,13 @@ import type { MonitorStatus, MonitorType } from "@/Types/Monitor"; import type { PaletteKey } from "@/Utils/Theme/Theme"; import type { ValueType } from "@/Components/design-elements/StatusLabel"; -import type { - DockerContainerMount, - DockerContainerPort, - DockerContainerState, +import { + DockerLogStreams, + type DockerContainerMount, + type DockerContainerPort, + type DockerContainerState, + type DockerLog, + type DockerLogLine, } from "@/Types/Check"; export const getMonitorPath = (type: MonitorType): string => { @@ -144,3 +147,35 @@ export const getDockerMountLabel = (mount: DockerContainerMount) => { if (mount.source) return mount.source; return mount.type; }; + +export const DockerLogStreamFilters = ["all", ...DockerLogStreams] as const; +export type DockerLogStreamFilter = (typeof DockerLogStreamFilters)[number]; + +export interface DockerLogRow extends DockerLogLine { + key: string; + gapBefore: boolean; // This signifies lines skipped +} + +export const flattenDockerLogs = (logs: DockerLog[]): DockerLogRow[] => { + const rows: DockerLogRow[] = []; + for (const log of [...logs].reverse()) { + log.lines.forEach((line: DockerLogLine, idx: number) => { + rows.push({ ...line, key: `${log.id}:${idx}`, gapBefore: idx === 0 && log.gap }); + }); + } + return rows; +}; + +export const filterDockerLogRows = ( + rows: DockerLogRow[], + stream: DockerLogStreamFilter, + query: string +): DockerLogRow[] => { + const normalizedQuery = query.trim().toLowerCase(); + return rows.filter((row) => { + return ( + (stream === "all" || row.stream === stream) && + (normalizedQuery === "" || row.text.toLowerCase().includes(normalizedQuery)) + ); + }); +}; From a5bb186814b96b6f6ed5d19f84c7e41f159a75e3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 11:28:10 -0700 Subject: [PATCH 09/23] wrap basebox in fowardRef --- client/src/Components/design-elements/BaseBox.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/src/Components/design-elements/BaseBox.tsx b/client/src/Components/design-elements/BaseBox.tsx index 580d15dcf..d5af820e6 100644 --- a/client/src/Components/design-elements/BaseBox.tsx +++ b/client/src/Components/design-elements/BaseBox.tsx @@ -1,10 +1,12 @@ import Box, { type BoxProps } from "@mui/material/Box"; import { useTheme } from "@mui/material/styles"; +import { forwardRef } from "react"; -export const BaseBox = ({ sx, ...rest }: BoxProps) => { +export const BaseBox = forwardRef(({ sx, ...rest }, ref) => { const theme = useTheme(); return ( { }} /> ); -}; +}); + +BaseBox.displayName = "BaseBox"; From 795e1caca4bb63bbec6f8293695659af811f9a38 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 11:42:11 -0700 Subject: [PATCH 10/23] log display --- .../Components/HeaderLogs.tsx | 16 +++ .../ContainerDetails/Components/RowLog.tsx | 74 +++++++++++ .../Pages/Docker/ContainerDetails/TabLogs.tsx | 119 ++++++++---------- .../Pages/Docker/ContainerDetails/index.tsx | 2 +- 4 files changed, 141 insertions(+), 70 deletions(-) create mode 100644 client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx create mode 100644 client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx diff --git a/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx b/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx new file mode 100644 index 000000000..b5cdec704 --- /dev/null +++ b/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx @@ -0,0 +1,16 @@ +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +export const HeaderLogs = ({ + lines, + isValidating, +}: { + lines: number; + isValidating: boolean; +}) => { + return ( + + {`${lines} recent lines`} + {isValidating && "Refreshing..."} + + ); +}; diff --git a/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx b/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx new file mode 100644 index 000000000..82ba9f259 --- /dev/null +++ b/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx @@ -0,0 +1,74 @@ +import Grid from "@mui/material/Grid"; +import Typography from "@mui/material/Typography"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Divider from "@mui/material/Divider"; +import { Fragment } from "react"; + +// Hooks +import { useTheme } from "@mui/material"; +import { useSelector } from "react-redux"; + +// Utils +import type { DockerLogRow } from "@/Utils/MonitorUtils"; +import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; +import { formatDateWithTz } from "@/Utils/TimeUtils"; +import type { RootState } from "@/store"; + +export const RowLog = ({ row }: { row: DockerLogRow }) => { + const theme = useTheme(); + const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + + return ( + + + + + {formatDateWithTz(row.ts, "MMM D HH:mm:ss", uiTimezone)} + + + + + + {row.stream} + + : {row.text} + + + + {row.gapBefore ? ( + + Some log entries were skipped + + + ) : ( + + )} + + ); +}; diff --git a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx index 9bd03e9bc..6d4087c53 100644 --- a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx +++ b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx @@ -1,30 +1,41 @@ // Components import Stack from "@mui/material/Stack"; import { BaseBox } from "@/Components/design-elements"; -import Typography from "@mui/material/Typography"; +import { HeaderLogs } from "@/Pages/Docker/ContainerDetails/Components/HeaderLogs"; // Types import type { DockerContainerLogsResponse } from "@/Types/Monitor"; // Hooks import { useGet } from "@/Hooks/UseApi"; -import { useState, useEffect, useMemo } from "react"; +import { + useState, + useEffect, + useLayoutEffect, + useMemo, + Fragment, + useRef, + useCallback, +} from "react"; import type { DockerLog } from "@/Types/Check"; -import { useSelector } from "react-redux"; - +import { useTheme } from "@mui/material"; // Util import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; import { flattenDockerLogs } from "@/Utils/MonitorUtils"; -import { Box } from "@mui/material"; -import { formatDateWithTz } from "@/Utils/TimeUtils"; -import type { RootState } from "@/store"; +import { RowLog } from "@/Pages/Docker/ContainerDetails/Components/RowLog"; interface TabLogsProps { monitorId?: string; containerName?: string; } +const SCROLL_PIN_THRESHOLD_PX = 8; +const isScrolledToBottom = (el: HTMLElement) => + el.scrollHeight - el.scrollTop - el.clientHeight <= SCROLL_PIN_THRESHOLD_PX; + export const TabLogs = ({ monitorId, containerName }: TabLogsProps) => { + const theme = useTheme(); + // State const [olderLogs, setOlderLogs] = useState([]); const [olderCursor, setOlderCursor] = useState(undefined); // undefined -> no older requested, string -> cursor, null -> no more data @@ -53,76 +64,46 @@ export const TabLogs = ({ monitorId, containerName }: TabLogsProps) => { ); const rows = useMemo(() => flattenDockerLogs(head?.logs ?? []), [head?.logs]); - const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + + const logBoxRef = useRef(null); + const pinnedToBottom = useRef(true); + + // Always pin to bottom when container/monitor changes + useLayoutEffect(() => { + pinnedToBottom.current = true; + }, [monitorId, containerName]); + + // New log rows + useLayoutEffect(() => { + const el = logBoxRef.current; + if (!el || !pinnedToBottom.current) return; + el.scrollTop = el.scrollHeight; + }, [rows]); + + const handleScroll = useCallback((event: React.UIEvent) => { + pinnedToBottom.current = isScrolledToBottom(event.currentTarget); + }, []); + return ( - - {`${rows.length} recent lines`} - {isValidating && "Refreshing..."} - + - {[...rows].reverse().map((row) => { + {rows.map((row) => { return ( - - - - {formatDateWithTz(row.ts, "MMM D HH:mm:ss", uiTimezone)} - - - - {row.stream} - - - - {row.text} - - - {row.gapBefore && ( - - - - {"Some lines were skipped between checks"} - - - - )} - + ); })} diff --git a/client/src/Pages/Docker/ContainerDetails/index.tsx b/client/src/Pages/Docker/ContainerDetails/index.tsx index 51f765785..eaf370318 100644 --- a/client/src/Pages/Docker/ContainerDetails/index.tsx +++ b/client/src/Pages/Docker/ContainerDetails/index.tsx @@ -21,7 +21,7 @@ const TAB_MAP = { logs: 1, } as const; -const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) => { +const DockerDetailsPage = ({ open = "logs" }: DockerContainerDetailsProps) => { const [activeTab, setActiveTab] = useState(TAB_MAP[open]); const [dateRange, setDateRange] = useState("recent"); const { monitorId, containerName } = useParams<{ From 37e1005479526f5ca9a87b24622b26f2e520654f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 11:54:29 -0700 Subject: [PATCH 11/23] add comment --- server/src/domain/docker/docker-log.service.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/src/domain/docker/docker-log.service.ts b/server/src/domain/docker/docker-log.service.ts index a67888de0..646666bab 100644 --- a/server/src/domain/docker/docker-log.service.ts +++ b/server/src/domain/docker/docker-log.service.ts @@ -76,6 +76,9 @@ export class DockerLogsService implements IDockerLogsService { }, lines: fresh, gap: fresh.length === DOCKER_LOG_TAIL_LINES, + // We only fetch DOCKER_LOG_TAIL_LINES worth of logs. + // If we have 200 fresh logs, there _could_ be more than 200 lines, so set gap = true + // Client can display that some logs may be missing checkedAt, expiry, createdAt: checkedAt, From 6e0afe223e2480a8b0d3030e2466cb4afeb0a528 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 12:06:17 -0700 Subject: [PATCH 12/23] add after to docker logs query --- .../src/api/controllers/monitorController.ts | 3 ++- .../src/api/validation/monitorValidation.ts | 20 ++++++++++++----- .../docker/docker-log.repository.interface.ts | 2 +- .../docker/docker-log.repository.mongo.ts | 8 +++++-- server/src/domain/monitors/monitor.service.ts | 8 +++++-- .../test/unit/services/monitorService.test.ts | 22 +++++++++++++++++++ .../unit/validation/monitorValidation.test.ts | 8 +++++++ 7 files changed, 60 insertions(+), 11 deletions(-) diff --git a/server/src/api/controllers/monitorController.ts b/server/src/api/controllers/monitorController.ts index 1da1d430e..12e8f8520 100644 --- a/server/src/api/controllers/monitorController.ts +++ b/server/src/api/controllers/monitorController.ts @@ -205,7 +205,7 @@ class MonitorController implements IMonitorController { const validatedQuery = getDockerContainerLogsQueryValidation.parse(req.query); const { monitorId, containerName } = validatedParams; - const { before, limit } = validatedQuery; + const { before, after, limit } = validatedQuery; const teamId = requireTeamId(req.user?.teamId); const data = await this.monitorService.getDockerContainerLogs({ @@ -213,6 +213,7 @@ class MonitorController implements IMonitorController { monitorId, containerName, before: before ? new Date(before) : undefined, + after: after ? new Date(after) : undefined, limit, }); diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 32d2abee2..880432325 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -333,11 +333,21 @@ export const getDockerContainerNameParamValidation = z.object({ }); export const getDockerContainerByNameQueryValidation = z.object({ dateRange: z.enum(DateRanges).optional() }); -export const getDockerContainerLogsQueryValidation = z.object({ - before: z.iso.datetime().optional(), - limit: z.coerce.number().int().min(1).max(DOCKER_LOG_PAGE_MAX).default(DOCKER_LOG_PAGE_DEFAULT), -}); - +export const getDockerContainerLogsQueryValidation = z + .object({ + before: z.iso.datetime().optional(), + after: z.iso.datetime().optional(), + limit: z.coerce.number().int().min(1).max(DOCKER_LOG_PAGE_MAX).default(DOCKER_LOG_PAGE_DEFAULT), + }) + .superRefine((query, ctx) => { + if (query.before && query.after) { + ctx.addIssue({ + code: "custom", + message: "Specify either before or after, not both", + path: ["after"], + }); + } + }); // 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/docker/docker-log.repository.interface.ts b/server/src/domain/docker/docker-log.repository.interface.ts index 59745ba4a..10f0ce8aa 100644 --- a/server/src/domain/docker/docker-log.repository.interface.ts +++ b/server/src/domain/docker/docker-log.repository.interface.ts @@ -3,7 +3,7 @@ import { DockerLog } from "@/domain/docker/docker-log.type.js"; export interface IDockerLogsRepository { createDockerLogs(logs: Omit[]): Promise; findLastLineTimestamp(monitorId: string, containerId: string): Promise; - findByContainerName(args: { monitorId: string; containerName: string; before?: Date; limit: number }): Promise; + findByContainerName(args: { monitorId: string; containerName: string; before?: Date; after?: Date; limit: number }): Promise; deleteByMonitorId(monitorId: string): Promise; deleteByTeamId(teamId: string): Promise; deleteByMonitorIdsNotIn(monitorIds: string[]): Promise; diff --git a/server/src/domain/docker/docker-log.repository.mongo.ts b/server/src/domain/docker/docker-log.repository.mongo.ts index f67d87a04..8ee90e535 100644 --- a/server/src/domain/docker/docker-log.repository.mongo.ts +++ b/server/src/domain/docker/docker-log.repository.mongo.ts @@ -64,22 +64,26 @@ class MongoDockerLogsRepository implements IDockerLogsRepository { monitorId, containerName, before, + after, limit, }: { monitorId: string; containerName: string; before?: Date; + after?: Date; limit: number; }): Promise => { const docs = await DockerLogModel.find({ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), "metadata.containerName": containerName, ...(before ? { checkedAt: { $lt: before } } : {}), + ...(after ? { checkedAt: { $gt: after } } : {}), }) - .sort({ checkedAt: -1 }) + .sort({ checkedAt: after ? 1 : -1 }) .limit(limit) .lean(); - return docs.map(this.toEntity); + const entities = docs.map(this.toEntity); + return after ? entities.reverse() : entities; }; deleteByMonitorId = async (monitorId: string) => diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index bc4750de6..43cba16d8 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -80,6 +80,7 @@ export interface IMonitorService { monitorId: string; containerName: string; before?: Date; + after?: Date; limit: number; }): Promise; getGeoChecksByMonitorId(args: { @@ -403,12 +404,14 @@ export class MonitorService implements IMonitorService { monitorId, containerName, before, + after, limit, }: { teamId: string; monitorId: string; containerName: string; before?: Date; + after?: Date; limit: number; }): Promise => { const monitor = await this.monitorsRepository.findById(monitorId, teamId); @@ -426,8 +429,9 @@ export class MonitorService implements IMonitorService { }); } - const logs = await this.dockerLogsRepository.findByContainerName({ monitorId, containerName, before, limit }); - return { logs, nextCursor: logs.length === limit ? (logs[logs.length - 1]?.checkedAt ?? null) : null }; + const logs = await this.dockerLogsRepository.findByContainerName({ monitorId, containerName, before, after, limit }); + const nextCursor = !after && logs.length === limit ? (logs[logs.length - 1]?.checkedAt ?? null) : null; + return { logs, nextCursor }; }; getGeoChecksByMonitorId = async ({ diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index f4ed45e5a..1bb4b3423 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -656,6 +656,28 @@ describe("MonitorService", () => { expect(result.nextCursor).toBeNull(); }); + + it("passes after through and returns a null cursor even for a full page", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const dockerLogsRepository = createDockerLogsRepositoryMock(); + const after = new Date("2026-01-01T00:00:00.000Z"); + const logs = [{ checkedAt: "2026-01-01T02:00:00.000Z" }, { checkedAt: "2026-01-01T01:00:00.000Z" }]; + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + (dockerLogsRepository.findByContainerName as jest.Mock).mockResolvedValue(logs); + const { service } = createService({ monitorsRepository, dockerLogsRepository }); + + const result = await service.getDockerContainerLogs({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + containerName: "web", + after, + limit: 2, + }); + + expect(dockerLogsRepository.findByContainerName).toHaveBeenCalledWith({ monitorId: MONITOR_ID, containerName: "web", after, limit: 2 }); + expect(result.logs).toBe(logs); + expect(result.nextCursor).toBeNull(); + }); }); describe("getPageSpeedDetailsById", () => { diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index a2ae3f0f5..96dbb30ce 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -18,6 +18,14 @@ describe("getDockerContainerLogsQueryValidation", () => { it("rejects a non-ISO before cursor", () => { expect(() => getDockerContainerLogsQueryValidation.parse({ before: "yesterday" })).toThrow(); }); + + it("rejects a non-ISO after cursor", () => { + expect(() => getDockerContainerLogsQueryValidation.parse({ after: "yesterday" })).toThrow(); + }); + + it("rejects before and after together", () => { + expect(() => getDockerContainerLogsQueryValidation.parse({ before: "2026-01-02T00:00:00.000Z", after: "2026-01-01T00:00:00.000Z" })).toThrow(); + }); }); const baseDnsBody = { From 1b97539eae2c2bf9919fcd2ad040d6b6bd091573 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 12:06:35 -0700 Subject: [PATCH 13/23] export API response interface --- client/src/Hooks/UseApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/Hooks/UseApi.ts b/client/src/Hooks/UseApi.ts index 543a9f165..343e0b558 100644 --- a/client/src/Hooks/UseApi.ts +++ b/client/src/Hooks/UseApi.ts @@ -6,7 +6,7 @@ import { useState } from "react"; import { useToast } from "@/Hooks/UseToast"; import { logger } from "@/Utils/logger"; -interface ApiResponse { +export interface ApiResponse { success: boolean; msg: string; data: T; From 7dbc7b30646897c01443d3e8ca4798a0dc86d9f1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 13:16:11 -0700 Subject: [PATCH 14/23] wrap showToast in useMemo --- client/src/Hooks/UseToast.ts | 43 +++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/client/src/Hooks/UseToast.ts b/client/src/Hooks/UseToast.ts index a0da495bc..29f340238 100644 --- a/client/src/Hooks/UseToast.ts +++ b/client/src/Hooks/UseToast.ts @@ -1,27 +1,30 @@ import { toast, type ToastOptions } from "react-toastify"; +import { useMemo } from "react"; export const useToast = () => { - const showToast = (message: string, options?: ToastOptions) => { - toast.dismiss(); - const baseStyle: React.CSSProperties = { - whiteSpace: "nowrap", - overflow: "hidden", - textOverflow: "ellipsis", + return useMemo(() => { + const showToast = (message: string, options?: ToastOptions) => { + toast.dismiss(); + const baseStyle: React.CSSProperties = { + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }; + toast(message, { + ...options, + style: { ...baseStyle, ...(options?.style || {}) }, + }); }; - toast(message, { - ...options, - style: { ...baseStyle, ...(options?.style || {}) }, - }); - }; - const toastSuccess = (msg: string, opts?: ToastOptions) => - showToast(msg, { ...opts, type: "success" }); - const toastError = (msg: string, opts?: ToastOptions) => - showToast(msg, { ...opts, type: "error" }); - const toastInfo = (msg: string, opts?: ToastOptions) => - showToast(msg, { ...opts, type: "info" }); - const toastWarning = (msg: string, opts?: ToastOptions) => - showToast(msg, { ...opts, type: "warning" }); + const toastSuccess = (msg: string, opts?: ToastOptions) => + showToast(msg, { ...opts, type: "success" }); + const toastError = (msg: string, opts?: ToastOptions) => + showToast(msg, { ...opts, type: "error" }); + const toastInfo = (msg: string, opts?: ToastOptions) => + showToast(msg, { ...opts, type: "info" }); + const toastWarning = (msg: string, opts?: ToastOptions) => + showToast(msg, { ...opts, type: "warning" }); - return { showToast, toastSuccess, toastError, toastInfo, toastWarning }; + return { showToast, toastSuccess, toastError, toastInfo, toastWarning }; + }, []); }; From e0129103328a28113fa5900d451a9002dd6351a6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 13:16:22 -0700 Subject: [PATCH 15/23] add loading older logs --- .../Components/HeaderLogs.tsx | 71 +++++++- .../ContainerDetails/Components/RowLog.tsx | 6 +- .../Pages/Docker/ContainerDetails/TabLogs.tsx | 172 ++++++++++++++---- client/src/locales/en.json | 12 ++ 4 files changed, 219 insertions(+), 42 deletions(-) diff --git a/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx b/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx index b5cdec704..b1ccddc2c 100644 --- a/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx +++ b/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx @@ -1,16 +1,69 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -export const HeaderLogs = ({ - lines, - isValidating, -}: { +import { Dot } from "@/Components/design-elements"; + +// Hooks +import { useEffect, useState } from "react"; +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; + +// Utils +import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; +import { MS_PER_SECOND, MS_PER_MINUTE } from "@/Utils/TimeUtils"; + +interface HeaderLogsProps { lines: number; - isValidating: boolean; -}) => { + loading: boolean; + lastUpdatedAt: number | null; +} + +export const HeaderLogs = ({ lines, loading, lastUpdatedAt }: HeaderLogsProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (lastUpdatedAt === null) return; + setNow(Date.now()); + const id = window.setInterval(() => setNow(Date.now()), MS_PER_SECOND); + return () => window.clearInterval(id); + }, [lastUpdatedAt]); + + const renderStatus = () => { + if (loading) return t("pages.docker.container.logs.loading"); + if (lastUpdatedAt === null) return null; + const ageMs = Math.max(0, now - lastUpdatedAt); + return ageMs < MS_PER_MINUTE + ? t("pages.docker.container.logs.updatedSecondsAgo", { + count: Math.floor(ageMs / MS_PER_SECOND), + }) + : t("pages.docker.container.logs.updatedMinutesAgo", { + count: Math.floor(ageMs / MS_PER_MINUTE), + }); + }; + return ( - - {`${lines} recent lines`} - {isValidating && "Refreshing..."} + + + {t("pages.docker.container.logs.recentLines", { count: lines })} + + + {lastUpdatedAt !== null && !loading && ( + + )} + {renderStatus()} + ); }; diff --git a/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx b/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx index 82ba9f259..4a022e44e 100644 --- a/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx +++ b/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx @@ -8,6 +8,7 @@ import { Fragment } from "react"; // Hooks import { useTheme } from "@mui/material"; import { useSelector } from "react-redux"; +import { useTranslation } from "react-i18next"; // Utils import type { DockerLogRow } from "@/Utils/MonitorUtils"; @@ -18,6 +19,7 @@ import type { RootState } from "@/store"; export const RowLog = ({ row }: { row: DockerLogRow }) => { const theme = useTheme(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + const { t } = useTranslation(); return ( @@ -63,7 +65,9 @@ export const RowLog = ({ row }: { row: DockerLogRow }) => { alignItems={"center"} py={SPACING.MD} > - Some log entries were skipped + + {t("pages.docker.container.logs.skipped")} + ) : ( diff --git a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx index 6d4087c53..eb5f9ce88 100644 --- a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx +++ b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx @@ -1,28 +1,33 @@ // Components import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; import { BaseBox } from "@/Components/design-elements"; +import { Button } from "@/Components/inputs"; import { HeaderLogs } from "@/Pages/Docker/ContainerDetails/Components/HeaderLogs"; +import { RowLog } from "@/Pages/Docker/ContainerDetails/Components/RowLog"; // Types import type { DockerContainerLogsResponse } from "@/Types/Monitor"; +import type { DockerLog } from "@/Types/Check"; +import type { ApiResponse } from "@/Hooks/UseApi"; // Hooks -import { useGet } from "@/Hooks/UseApi"; import { useState, useEffect, useLayoutEffect, useMemo, - Fragment, useRef, useCallback, } from "react"; -import type { DockerLog } from "@/Types/Check"; import { useTheme } from "@mui/material"; -// Util -import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; +import { useTranslation } from "react-i18next"; +import { useToast } from "@/Hooks/UseToast"; + +// Utils +import { get } from "@/Utils/ApiClient"; +import { LAYOUT } from "@/Utils/Theme/constants"; import { flattenDockerLogs } from "@/Utils/MonitorUtils"; -import { RowLog } from "@/Pages/Docker/ContainerDetails/Components/RowLog"; interface TabLogsProps { monitorId?: string; @@ -30,74 +35,177 @@ interface TabLogsProps { } const SCROLL_PIN_THRESHOLD_PX = 8; +const TAIL_POLL_MS = 10_000; + const isScrolledToBottom = (el: HTMLElement) => el.scrollHeight - el.scrollTop - el.clientHeight <= SCROLL_PIN_THRESHOLD_PX; +const fetchLogs = async (url: string, params: { before?: string; after?: string }) => { + const res = await get>(url, { params }); + return res.data.data; +}; + export const TabLogs = ({ monitorId, containerName }: TabLogsProps) => { const theme = useTheme(); + const { t } = useTranslation(); + const { toastError } = useToast(); - // State - const [olderLogs, setOlderLogs] = useState([]); - const [olderCursor, setOlderCursor] = useState(undefined); // undefined -> no older requested, string -> cursor, null -> no more data + // logs is newest-first, matching the API and what flattenDockerLogs expects. + const [logs, setLogs] = useState([]); + const [olderExhausted, setOlderExhausted] = useState(false); + const [loadingInitial, setLoadingInitial] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false); - const [odlerError, setOlderError] = useState(null); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); - const baseLogsUrl = + const logsUrl = monitorId && containerName ? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs` : null; - const { - data: head, - isLoading, - isValidating, - error: headError, - refetch, - } = useGet( - baseLogsUrl, - {}, - { - refreshInterval: 10_000, - keepPreviousData: true, - revalidateOnFocus: true, - } - ); + const newestCursor = logs[0]?.checkedAt; + const oldestCursor = logs[logs.length - 1]?.checkedAt; - const rows = useMemo(() => flattenDockerLogs(head?.logs ?? []), [head?.logs]); + // Initial page. Resets everything when the container changes. + useEffect(() => { + setLogs([]); + setOlderExhausted(false); + if (!logsUrl) return; + let cancelled = false; + setLoadingInitial(true); + fetchLogs(logsUrl, {}) + .then((page) => { + if (cancelled) return; + setLogs(page.logs); + setOlderExhausted(page.nextCursor === null); + setLastUpdatedAt(Date.now()); + }) + .catch(() => { + if (cancelled) return; + toastError(t("pages.docker.container.logs.loadError")); + }) + .finally(() => { + if (!cancelled) setLoadingInitial(false); + }); + return () => { + cancelled = true; + }; + }, [logsUrl, t, toastError]); + + const newestCursorRef = useRef(newestCursor); + newestCursorRef.current = newestCursor; + + useEffect(() => { + if (!logsUrl) return; + let inFlight = false; + const tick = async () => { + if (inFlight || document.hidden) return; + inFlight = true; + try { + const cursor = newestCursorRef.current; + const page = await fetchLogs(logsUrl, cursor ? { after: cursor } : {}); + if (page.logs.length > 0) setLogs((prev) => [...page.logs, ...prev]); + setLastUpdatedAt(Date.now()); + } catch (err: unknown) { + console.error(err); + } finally { + inFlight = false; + } + }; + const id = window.setInterval(tick, TAIL_POLL_MS); + document.addEventListener("visibilitychange", tick); + return () => { + window.clearInterval(id); + document.removeEventListener("visibilitychange", tick); + }; + }, [logsUrl]); + + const rows = useMemo(() => flattenDockerLogs(logs), [logs]); const logBoxRef = useRef(null); const pinnedToBottom = useRef(true); + const prependAnchor = useRef<{ scrollHeight: number; scrollTop: number } | null>(null); // Always pin to bottom when container/monitor changes useLayoutEffect(() => { pinnedToBottom.current = true; }, [monitorId, containerName]); - // New log rows + // Rows changed: restore position after a prepend, otherwise honour the pin useLayoutEffect(() => { const el = logBoxRef.current; - if (!el || !pinnedToBottom.current) return; - el.scrollTop = el.scrollHeight; + if (!el) return; + const anchor = prependAnchor.current; + if (anchor) { + prependAnchor.current = null; + el.scrollTop = el.scrollHeight - anchor.scrollHeight + anchor.scrollTop; + return; + } + if (pinnedToBottom.current) el.scrollTop = el.scrollHeight; }, [rows]); const handleScroll = useCallback((event: React.UIEvent) => { pinnedToBottom.current = isScrolledToBottom(event.currentTarget); }, []); + const loadOlder = useCallback(async () => { + if (!logsUrl || !oldestCursor || loadingOlder) return; + setLoadingOlder(true); + try { + const page = await fetchLogs(logsUrl, { before: oldestCursor }); + const el = logBoxRef.current; + if (el) + prependAnchor.current = { + scrollHeight: el.scrollHeight, + scrollTop: el.scrollTop, + }; + setLogs((prev) => [...prev, ...page.logs]); + setOlderExhausted(page.nextCursor === null); + } catch { + toastError(t("pages.docker.container.logs.loadOlderError")); + } finally { + setLoadingOlder(false); + } + }, [logsUrl, oldestCursor, loadingOlder, t, toastError]); + return ( + {rows.length > 0 && ( + + {olderExhausted ? ( + + {t("pages.docker.container.logs.noOlder")} + + ) : ( + + )} + + )} {rows.map((row) => { return ( Date: Fri, 4 Sep 2026 14:06:46 -0700 Subject: [PATCH 16/23] add opt in docker logs flag --- .../src/api/validation/monitorValidation.ts | 5 ++++ server/src/domain/monitors/monitor.model.ts | 4 +++ .../monitors/monitor.repository.mongo.ts | 1 + server/src/domain/monitors/monitor.type.ts | 1 + server/src/service/network/DockerProvider.ts | 9 ++++-- .../providers/network/dockerProvider.test.ts | 28 +++++++++++++++---- .../unit/validation/monitorValidation.test.ts | 13 +++++++++ 7 files changed, 54 insertions(+), 7 deletions(-) diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 880432325..c11df8f80 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -177,6 +177,7 @@ export const createMonitorBodyValidation = z geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), + dockerLogsEnabled: z.boolean().optional(), dnsServer: dnsServerValidation.optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), }) @@ -222,6 +223,7 @@ export const editMonitorBodyValidation = z geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), + dockerLogsEnabled: z.boolean().optional(), dnsServer: dnsServerValidation.optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), }) @@ -298,6 +300,8 @@ const importedMonitorSchema = z geoCheckEnabled: z.boolean().default(false), geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), geoCheckInterval: z.number().min(300000).default(300000), + dockerLogsEnabled: z.boolean().default(false), + dnsServer: dnsServerValidation.optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), createdAt: z.string().optional(), @@ -387,6 +391,7 @@ export const monitorResponseSchema = z geoCheckEnabled: z.boolean(), geoCheckLocations: z.array(z.enum(GeoContinents)), geoCheckInterval: z.number(), + dockerLogsEnabled: z.boolean(), dnsServer: z.string().optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), teamId: z.string(), diff --git a/server/src/domain/monitors/monitor.model.ts b/server/src/domain/monitors/monitor.model.ts index 89888900a..f45b613a2 100644 --- a/server/src/domain/monitors/monitor.model.ts +++ b/server/src/domain/monitors/monitor.model.ts @@ -306,6 +306,10 @@ const MonitorSchema = new Schema( type: Number, default: 300000, }, + dockerLogsEnabled: { + type: Boolean, + default: false, + }, dnsServer: { type: String, }, diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index 9617ba3f6..d31d830ac 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -454,6 +454,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckInterval: doc.geoCheckInterval ?? 300000, + dockerLogsEnabled: doc.dockerLogsEnabled ?? false, dnsServer: doc.dnsServer ?? undefined, dnsRecordType: doc.dnsRecordType ?? undefined, createdAt: toDateString(doc.createdAt), diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index 49fc3cf57..587112c24 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -109,6 +109,7 @@ export interface Monitor { geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; + dockerLogsEnabled?: boolean; dnsServer?: string; dnsRecordType?: DnsRecordType; recentChecks: CheckSnapshot[]; diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index f2ece3f4e..38bb59fef 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -279,6 +279,11 @@ export class DockerProvider implements IStatusProvider { } }; + private collectLogs = async (docker: Dockerode, summaries: Dockerode.ContainerInfo[]): Promise => { + const rawLogs = await this.mapWithConcurrency(summaries, STATS_CONCURRENCY, (s) => this.toContainerLogs(docker, s)); + return rawLogs.filter((log) => log !== null); + }; + handle = async (monitor: Monitor): Promise> => { try { const docker = new this.DockerLib(this.toDockerOptions(monitor)); @@ -307,8 +312,8 @@ export class DockerProvider implements IStatusProvider { const summaries = await docker.listContainers({ all: true }); const containers = await this.mapWithConcurrency(summaries, STATS_CONCURRENCY, (s) => this.toContainerInfo(docker, s)); - const rawLogs = await this.mapWithConcurrency(summaries, STATS_CONCURRENCY, (s) => this.toContainerLogs(docker, s)); - const logs = rawLogs.filter((log) => log !== null); + const logs = monitor.dockerLogsEnabled ? await this.collectLogs(docker, summaries) : undefined; + const summary: DockerContainerSummary = { total: containers.length, running: containers.filter((c) => c.state === "running").length, diff --git a/server/test/unit/providers/network/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts index df202e3fd..ba9d90568 100644 --- a/server/test/unit/providers/network/dockerProvider.test.ts +++ b/server/test/unit/providers/network/dockerProvider.test.ts @@ -297,7 +297,7 @@ describe("DockerProvider", () => { ); const { provider } = setup({ logs }); - const result = await provider.handle(makeMonitor()); + const result = await provider.handle(makeMonitor({ dockerLogsEnabled: true })); expect(result.payload?.logs?.[0]?.lines).toEqual([ { ts: "2026-01-01T00:00:37.100000000Z", stream: "stdout", text: "ready" }, @@ -309,7 +309,7 @@ describe("DockerProvider", () => { const logs = jest.fn().mockResolvedValue(Buffer.from("noise\n2026-01-01T00:00:00Z first\n2026-01-01T00:00:01.12Z second\n")); const { provider } = setup({ logs }); - const result = await provider.handle(makeMonitor()); + const result = await provider.handle(makeMonitor({ dockerLogsEnabled: true })); expect(result.payload?.logs?.[0]?.lines).toEqual([ { ts: "2026-01-01T00:00:00.000000000Z", stream: "stdout", text: "first" }, @@ -321,7 +321,7 @@ describe("DockerProvider", () => { const logs = jest.fn().mockResolvedValue(Buffer.from(`2026-01-01T00:00:00Z ${"x".repeat(5000)}\n`)); const { provider } = setup({ logs }); - const result = await provider.handle(makeMonitor()); + const result = await provider.handle(makeMonitor({ dockerLogsEnabled: true })); const text = result.payload?.logs?.[0]?.lines[0]?.text; expect(text).toEqual(expect.stringMatching(/ …\[truncated\]$/)); expect(Buffer.byteLength(text?.replace(" …[truncated]", "") ?? "", "utf8")).toBeLessThanOrEqual(4096); @@ -330,7 +330,7 @@ describe("DockerProvider", () => { it("isolates a logs failure and warns", async () => { const { provider, logger } = setup({ logs: jest.fn().mockRejectedValue(new Error("logs failed")) }); - const result = await provider.handle(makeMonitor()); + const result = await provider.handle(makeMonitor({ dockerLogsEnabled: true })); expect(result.payload?.logs).toEqual([]); expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to read logs for container my-container" })); @@ -340,11 +340,29 @@ describe("DockerProvider", () => { const containers = [makeContainer({ Id: "a".repeat(64) }), makeContainer({ Id: "b".repeat(64), State: "exited" })]; const { provider, logs } = setup({ listContainers: jest.fn().mockResolvedValue(containers) }); - await provider.handle(makeMonitor()); + await provider.handle(makeMonitor({ dockerLogsEnabled: true })); expect(logs).toHaveBeenCalledTimes(2); expect(logs).toHaveBeenCalledWith({ follow: false, stdout: true, stderr: true, timestamps: true, tail: DOCKER_LOG_TAIL_LINES }); }); + + it("does not read logs when dockerLogsEnabled is false", async () => { + const { provider, logs } = setup(); + + const result = await provider.handle(makeMonitor({ dockerLogsEnabled: false })); + + expect(logs).not.toHaveBeenCalled(); + expect(result.payload?.logs).toBeUndefined(); + }); + + it("does not read logs when dockerLogsEnabled is unset", async () => { + const { provider, logs } = setup(); + + const result = await provider.handle(makeMonitor()); + + expect(logs).not.toHaveBeenCalled(); + expect(result.payload?.logs).toBeUndefined(); + }); }); // ── Enrichment fault isolation ─────────────────────────────────────── diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index 96dbb30ce..bae34f830 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -233,6 +233,19 @@ describe("monitorValidation — strategy gating", () => { expect(parsed.monitors[0].strategy).toBeUndefined(); }); + it("defaults dockerLogsEnabled to false on imported docker monitors", () => { + const parsed = importMonitorsBodyValidation.parse({ + monitors: [ + { + name: "Imported Docker", + type: "docker", + url: "unix:///var/run/docker.sock", + }, + ], + }); + expect(parsed.monitors[0].dockerLogsEnabled).toBe(false); + }); + it("rejects strategy on imported non-pagespeed monitors", () => { expect(() => importMonitorsBodyValidation.parse({ From c1af8995e40aba4fbc2354511a88a31cedef0b0c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 14:07:10 -0700 Subject: [PATCH 17/23] add opt in docker logs --- client/src/Hooks/useMonitorForm.ts | 1 + client/src/Pages/CreateMonitor/index.tsx | 18 +++++++++++++++++- client/src/Types/Monitor.ts | 1 + client/src/Validation/monitor.ts | 1 + client/src/locales/en.json | 9 +++++++++ 5 files changed, 29 insertions(+), 1 deletion(-) diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 5458a75d8..043796ccb 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -77,6 +77,7 @@ export const getMonitorDefaults = ( ...base, type: "docker", url: data?.url || "", + dockerLogsEnabled: data?.dockerLogsEnabled ?? false, }; break; case "game": diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 5c2b693e0..443170ba7 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -255,6 +255,7 @@ const CreateMonitorPage = () => { const firstSegment = pathSegments[0]; if (firstSegment === "pagespeed") return "pagespeed"; if (firstSegment === "infrastructure") return "hardware"; + if (firstSegment === "docker") return "docker"; return "uptime"; }, [location.pathname]); @@ -264,7 +265,9 @@ const CreateMonitorPage = () => { ? "pagespeed" : pageType === "hardware" ? "hardware" - : "http"; + : pageType === "docker" + ? "docker" + : "http"; const { data: existingMonitor, refetch: refetchMonitor } = useGet( isEditMode ? `/monitors/${monitorId}` : null @@ -823,6 +826,19 @@ const CreateMonitorPage = () => { /> )} + {showStep(1) && watchedType === "docker" && ( + + } + /> + )} + {showStep(2) && (watchedType === "http" || watchedType === "grpc" || diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 300bd2ce4..c81df9c82 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -159,6 +159,7 @@ export interface Monitor { geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; + dockerLogsEnabled?: boolean; dnsServer?: string; dnsRecordType?: DnsRecordType; recentChecks: CheckSnapshot[]; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 00ab3aaff..0cc832405 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -112,6 +112,7 @@ const portSchema = baseSchema.extend({ const dockerSchema = baseSchema.extend({ type: z.literal("docker"), url: z.string().min(1, "Container ID is required"), + dockerLogsEnabled: z.boolean().register(monitorStepRegistry, { step: 1 }), }); // Game server monitor schema diff --git a/client/src/locales/en.json b/client/src/locales/en.json index af1a11f4b..ce8ff03dc 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -814,6 +814,15 @@ } } }, + "dockerLogs": { + "title": "Container logs", + "description": "Collect the most recent log lines from every container on this host on each check. Logs are kept for 7 days. Leave this off for hosts with chatty containers to save database space.", + "option": { + "enabled": { + "label": "Collect container logs" + } + } + }, "stats": { "title": "Monitor history", "description": "Clear all monitoring history and statistics for your team. This action is irreversible.", From 074b94b061785704c5b0ddf9b7ad1188e57296dd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 14:09:41 -0700 Subject: [PATCH 18/23] fix stale docker strings --- client/src/Pages/CreateMonitor/index.tsx | 4 ++-- client/src/Validation/monitor.ts | 2 +- client/src/locales/en.json | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 443170ba7..afdc9a54c 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -118,8 +118,8 @@ const getGeneralSettingsConfig = ( showDnsRecordType: false, }, docker: { - urlLabel: t("pages.createMonitor.form.general.option.container.label"), - urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"), + urlLabel: t("pages.createMonitor.form.general.option.dockerHost.label"), + urlPlaceholder: t("pages.createMonitor.form.general.option.dockerHost.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, showProxy: false, diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 0cc832405..3a1f479dd 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -111,7 +111,7 @@ const portSchema = baseSchema.extend({ // Docker monitor schema const dockerSchema = baseSchema.extend({ type: z.literal("docker"), - url: z.string().min(1, "Container ID is required"), + url: z.string().min(1, "Docker host URL is required"), dockerLogsEnabled: z.boolean().register(monitorStepRegistry, { step: 1 }), }); diff --git a/client/src/locales/en.json b/client/src/locales/en.json index ce8ff03dc..438a2575b 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -632,9 +632,9 @@ }, "general": { "option": { - "container": { - "label": "Container name/ID", - "placeholder": "my-app or abcd1234" + "dockerHost": { + "label": "Docker host", + "placeholder": "unix:///var/run/docker.sock or ssh://user@host" }, "host": { "label": "Host", @@ -716,7 +716,7 @@ "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard. Set an optional proxy server to connect through.", "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.", - "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).", + "docker": "Enter the Docker host to monitor: a local socket (unix:///var/run/docker.sock or /var/run/docker.sock) or a remote host over SSH (ssh://user@host, with an optional :port). Every container on the host is monitored.", "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type. Some games require enabling server queries in their config file — check your game server's documentation. See the supported games list for protocol details.", "pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.", "grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.", @@ -756,7 +756,7 @@ }, "type": { "description": "Select the type of check to perform", - "optionDockerDescription": "Use Docker to monitor if a container is running.", + "optionDockerDescription": "Monitor a Docker host and every container running on it.", "optionGameDescription": "Monitor if a specific game server is online.", "optionGrpcDescription": "Monitor gRPC services using the standard Health Checking Protocol.", "optionWebSocketDescription": "Monitor WebSocket endpoints for connection health and response time.", From 71fed33ecde265cb7495158216c25f4bcc6e5154 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 14:19:11 -0700 Subject: [PATCH 19/23] purge ssh --- client/src/locales/en.json | 4 +-- .../src/api/validation/monitorValidation.ts | 17 ++------- .../0012_migrateDockerMonitorUrls.ts | 6 ++-- server/src/domain/monitors/monitor.model.ts | 3 -- .../monitors/monitor.repository.mongo.ts | 1 - server/src/domain/monitors/monitor.type.ts | 1 - server/src/service/network/DockerProvider.ts | 26 +------------- .../providers/network/dockerProvider.test.ts | 35 +------------------ .../unit/validation/monitorValidation.test.ts | 24 ++----------- 9 files changed, 13 insertions(+), 104 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 438a2575b..b3aa6467c 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -634,7 +634,7 @@ "option": { "dockerHost": { "label": "Docker host", - "placeholder": "unix:///var/run/docker.sock or ssh://user@host" + "placeholder": "unix:///var/run/docker.sock" }, "host": { "label": "Host", @@ -716,7 +716,7 @@ "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard. Set an optional proxy server to connect through.", "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.", - "docker": "Enter the Docker host to monitor: a local socket (unix:///var/run/docker.sock or /var/run/docker.sock) or a remote host over SSH (ssh://user@host, with an optional :port). Every container on the host is monitored.", + "docker": "Enter the Docker socket to monitor, as unix:///var/run/docker.sock or an absolute path such as /var/run/docker.sock. Every container on the host is monitored.", "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type. Some games require enabling server queries in their config file — check your game server's documentation. See the supported games list for protocol details.", "pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.", "grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.", diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index c11df8f80..ddea832ef 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -120,22 +120,15 @@ const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ct } }; -const dockerUrlRegex = /^(?:ssh:\/\/[^@\s]+@[^\s/:@]+(?::\d{1,5})?\/?|unix:\/\/\/\S+|\/\S+)$/; +const dockerUrlRegex = /^(?:unix:\/\/\/\S+|\/\S+)$/; -const refineDockerUrl = (data: { type?: string; url?: string; sshPrivateKey?: string }, ctx: z.RefinementCtx) => { +const refineDockerUrl = (data: { type?: string; url?: string }, ctx: z.RefinementCtx) => { if (data.type !== "docker" || data.url === undefined) return; if (!dockerUrlRegex.test(data.url)) { ctx.addIssue({ code: "custom", path: ["url"], - message: "Docker host must be ssh://user@host[:port], unix:///path, or an absolute socket path", - }); - } - if (data.url.startsWith("ssh://") && !data.sshPrivateKey) { - ctx.addIssue({ - code: "custom", - path: ["sshPrivateKey"], - message: "SSH Docker hosts require a private key", + message: "Docker host must be unix:///path or an absolute socket path", }); } }; @@ -164,7 +157,6 @@ export const createMonitorBodyValidation = z tags: z.array(z.string()).optional(), customUpCodes: z.array(httpStatusCode).default([]), secret: z.string().optional(), - sshPrivateKey: z.string().optional(), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), @@ -201,7 +193,6 @@ export const editMonitorBodyValidation = z tags: z.array(z.string()).optional(), customUpCodes: z.array(httpStatusCode).optional(), secret: z.string().optional(), - sshPrivateKey: z.string().optional(), ignoreTlsErrors: z.boolean().optional(), proxyMode: z.enum(ProxyModes).optional(), proxyId: proxyIdValidation, @@ -283,7 +274,6 @@ const importedMonitorSchema = z tags: z.array(z.string()).default([]), customUpCodes: z.array(httpStatusCode).default([]), secret: z.string().optional(), - sshPrivateKey: z.string().optional(), cpuAlertThreshold: z.number().default(100), cpuAlertCounter: z.number().default(5), memoryAlertThreshold: z.number().default(100), @@ -379,7 +369,6 @@ export const monitorResponseSchema = z tags: z.array(z.string()), customUpCodes: z.array(httpStatusCode).optional(), secret: z.string().optional(), - sshPrivateKey: z.string().optional(), cpuAlertThreshold: z.number(), memoryAlertThreshold: z.number(), diskAlertThreshold: z.number(), diff --git a/server/src/db/migration/0012_migrateDockerMonitorUrls.ts b/server/src/db/migration/0012_migrateDockerMonitorUrls.ts index 75f3d5acf..d4d3bd143 100644 --- a/server/src/db/migration/0012_migrateDockerMonitorUrls.ts +++ b/server/src/db/migration/0012_migrateDockerMonitorUrls.ts @@ -7,8 +7,8 @@ import type { ILogger } from "@/utils/logger.js"; * The old docker provider interpreted monitor.url as a container name/ID and only * ever talked to the local socket, so every pre-existing docker monitor's host is * definitionally unix:///var/run/docker.sock. The container name is preserved in - * the description. Urls already in host form (unix://, ssh://, or an absolute - * socket path) are left untouched, which makes the migration idempotent. + * the description. Urls already in host form (unix:// or an absolute socket + * path) are left untouched, which makes the migration idempotent. */ export async function migrateDockerMonitorUrls(logger: ILogger): Promise { const SERVICE_NAME = "Migration:MigrateDockerMonitorUrls"; @@ -16,7 +16,7 @@ export async function migrateDockerMonitorUrls(logger: ILogger): Promise { try { logger.info({ service: SERVICE_NAME, message: "Starting docker monitor url migration" }); - const result = await MonitorModel.updateMany({ type: "docker", url: { $not: /^((unix|ssh):\/\/|\/)/ } }, [ + const result = await MonitorModel.updateMany({ type: "docker", url: { $not: /^(unix:\/\/|\/)/ } }, [ { $set: { description: { diff --git a/server/src/domain/monitors/monitor.model.ts b/server/src/domain/monitors/monitor.model.ts index f45b613a2..2517f5d8e 100644 --- a/server/src/domain/monitors/monitor.model.ts +++ b/server/src/domain/monitors/monitor.model.ts @@ -235,9 +235,6 @@ const MonitorSchema = new Schema( secret: { type: String, }, - sshPrivateKey: { - type: String, - }, cpuAlertThreshold: { type: Number, default: 100, diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index d31d830ac..db6b79853 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -436,7 +436,6 @@ class MongoMonitorsRepository implements IMonitorsRepository { tags: tagIds, customUpCodes: doc.customUpCodes ?? [], secret: doc.secret ?? undefined, - sshPrivateKey: doc.sshPrivateKey ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, cpuAlertCounter: doc.cpuAlertCounter, memoryAlertThreshold: doc.memoryAlertThreshold, diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index 587112c24..12fcd1036 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -92,7 +92,6 @@ export interface Monitor { tags: string[]; customUpCodes: HttpStatusCode[]; secret?: string; - sshPrivateKey?: string; cpuAlertThreshold: number; cpuAlertCounter: number; memoryAlertThreshold: number; diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index 38bb59fef..776da3f92 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -63,31 +63,7 @@ export class DockerProvider implements IStatusProvider { return { socketPath }; } if (url.startsWith("/")) return { socketPath: url }; - const match = url.match(/^ssh:\/\/(?:([^@\s]+)@)?([^\s/:@]+)(?::(\d{1,5}))?\/?$/); - if (!match) throw new AppError({ message: `Invalid Docker host URL: ${url}`, status: 422, service: SERVICE_NAME, method: "toDockerOptions" }); - const [, username, host, port] = match; - if (!username) - throw new AppError({ - message: "SSH Docker host URL requires a user: ssh://user@host", - status: 422, - service: SERVICE_NAME, - method: "toDockerOptions", - }); - - if (!monitor.sshPrivateKey) - throw new AppError({ - message: "SSH Docker host requires a private key", - status: 422, - service: SERVICE_NAME, - method: "toDockerOptions", - }); - return { - protocol: "ssh", - host, - port: port ? Number(port) : 22, - username, - sshOptions: { privateKey: monitor.sshPrivateKey }, - }; + throw new AppError({ message: `Invalid Docker host URL: ${url}`, status: 422, service: SERVICE_NAME, method: "toDockerOptions" }); }; private async mapWithConcurrency(items: T[], limit: number, fn: (item: T) => Promise): Promise { diff --git a/server/test/unit/providers/network/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts index ba9d90568..4c27985ef 100644 --- a/server/test/unit/providers/network/dockerProvider.test.ts +++ b/server/test/unit/providers/network/dockerProvider.test.ts @@ -9,8 +9,6 @@ import { DOCKER_LOG_TAIL_LINES } from "../../../../src/domain/docker/docker.type // ── Fixtures ───────────────────────────────────────────────────────────────── -const PRIVATE_KEY = "-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n-----END OPENSSH PRIVATE KEY-----"; - const makeMonitor = (overrides?: Partial): Monitor => ({ id: "mon-1", @@ -116,28 +114,6 @@ describe("DockerProvider", () => { expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/var/run/docker.sock" }); }); - - it("parses ssh urls with a private key, defaulting to port 22", async () => { - const { provider, DockerLib } = setup(); - - await provider.handle(makeMonitor({ url: "ssh://deploy@prod-swarm-01.internal", sshPrivateKey: PRIVATE_KEY })); - - expect(DockerLib).toHaveBeenCalledWith({ - protocol: "ssh", - host: "prod-swarm-01.internal", - port: 22, - username: "deploy", - sshOptions: { privateKey: PRIVATE_KEY }, - }); - }); - - it("parses an explicit ssh port", async () => { - const { provider, DockerLib } = setup(); - - await provider.handle(makeMonitor({ url: "ssh://deploy@host:2222", sshPrivateKey: PRIVATE_KEY })); - - expect(DockerLib).toHaveBeenCalledWith(expect.objectContaining({ port: 2222 })); - }); }); // ── Fail-loudly url validation ─────────────────────────────────────── @@ -151,7 +127,7 @@ describe("DockerProvider", () => { ["a container name (old semantics)", "my-container", "Invalid Docker host URL"], ["tcp engine urls (unsupported)", "tcp://host:2375", "Invalid Docker host URL"], ["https engine urls (unsupported)", "https://host", "Invalid Docker host URL"], - ["ssh without a user", "ssh://host", "SSH Docker host URL requires a user: ssh://user@host"], + ["ssh engine urls (unsupported)", "ssh://deploy@host", "Invalid Docker host URL"], ]; it.each(invalidUrls)("throws AppError for %s and never constructs a client", async (_label, url, message) => { @@ -161,15 +137,6 @@ describe("DockerProvider", () => { expect(DockerLib).not.toHaveBeenCalled(); }); - it("throws AppError for an ssh url with no private key", async () => { - const { provider, DockerLib } = setup(); - - await expect(provider.handle(makeMonitor({ url: "ssh://deploy@host", sshPrivateKey: undefined }))).rejects.toThrow( - "SSH Docker host requires a private key" - ); - expect(DockerLib).not.toHaveBeenCalled(); - }); - it("preserves the toDockerOptions AppError through handle's catch", async () => { const { provider } = setup(); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index bae34f830..1cd224ab5 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -608,7 +608,6 @@ describe("monitorValidation — Docker host url", () => { type: "docker" as const, url: "unix:///var/run/docker.sock", }; - const PRIVATE_KEY = "-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n-----END OPENSSH PRIVATE KEY-----"; describe("createMonitorBodyValidation", () => { it("accepts the default unix socket url", () => { @@ -621,32 +620,15 @@ describe("monitorValidation — Docker host url", () => { expect(parsed.url).toBe("/run/docker.sock"); }); - it("accepts an ssh url with a private key and retains the key", () => { - for (const url of ["ssh://deploy@host", "ssh://deploy@host:2222"]) { - const parsed = createMonitorBodyValidation.parse({ ...baseDockerBody, url, sshPrivateKey: PRIVATE_KEY }); - expect(parsed.sshPrivateKey).toBe(PRIVATE_KEY); - } - }); - it("rejects container names and unsupported engine urls", () => { - for (const badUrl of ["my-container", "tcp://host:2375", "https://host", "unix://relative/path", ""]) { + for (const badUrl of ["my-container", "tcp://host:2375", "https://host", "ssh://deploy@host", "unix://relative/path", ""]) { expect(() => createMonitorBodyValidation.parse({ ...baseDockerBody, url: badUrl })).toThrow(); } }); - it("rejects an ssh url without a user", () => { - expect(() => createMonitorBodyValidation.parse({ ...baseDockerBody, url: "ssh://host", sshPrivateKey: PRIVATE_KEY })).toThrow(); - }); - - it("rejects an ssh url without a private key, attributing the issue to sshPrivateKey", () => { - const result = createMonitorBodyValidation.safeParse({ ...baseDockerBody, url: "ssh://deploy@host" }); - expect(result.success).toBe(false); - expect(result.error?.issues.some((issue) => issue.path.includes("sshPrivateKey"))).toBe(true); - }); - it("does not apply docker url rules to other monitor types", () => { const parsed = createMonitorBodyValidation.parse({ name: "HTTP check", type: "http", url: "https://example.com" }); - expect(parsed.sshPrivateKey).toBeUndefined(); + expect(parsed.url).toBe("https://example.com"); }); }); @@ -660,7 +642,7 @@ describe("monitorValidation — Docker host url", () => { expect(() => editMonitorBodyValidation.parse({ type: "docker", url: "my-container" })).toThrow(); }); - it("rejects a docker edit switching to ssh without a key", () => { + it("rejects a docker edit with an ssh url", () => { expect(() => editMonitorBodyValidation.parse({ type: "docker", url: "ssh://deploy@host" })).toThrow(); }); }); From 9a541dac21b661c08e9d585d7133c73268b833cf Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 14:25:27 -0700 Subject: [PATCH 20/23] mock missing dependency --- server/test/helpers/heartbeatTestHarness.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/test/helpers/heartbeatTestHarness.ts b/server/test/helpers/heartbeatTestHarness.ts index 9c2161790..0661d6445 100644 --- a/server/test/helpers/heartbeatTestHarness.ts +++ b/server/test/helpers/heartbeatTestHarness.ts @@ -128,6 +128,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness { const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) }; const proxyResolver = { resolve: jest.fn().mockResolvedValue(undefined) }; + const dockerLogsService = { buildDockerLogs: jest.fn().mockResolvedValue([]) }; const checkProducer = new CheckProducer( monitorsRepo as any, @@ -136,6 +137,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness { networkService as any, proxyResolver as any, bufferStub as any, + dockerLogsService as any, logger ); const checkEvaluator = new CheckEvaluator(statusService as any, new MonitorStatusPolicy()); From e2362ca80905419917531d7e21644922759e540d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 14:34:24 -0700 Subject: [PATCH 21/23] add empty view if logs disabled --- .../Pages/Docker/ContainerDetails/TabLogs.tsx | 18 +++++++++++++++--- .../Pages/Docker/ContainerDetails/index.tsx | 1 + client/src/locales/en.json | 7 ++++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx index eb5f9ce88..8839d553b 100644 --- a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx +++ b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx @@ -1,7 +1,7 @@ // Components import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -import { BaseBox } from "@/Components/design-elements"; +import { BaseBox, EmptyState } from "@/Components/design-elements"; import { Button } from "@/Components/inputs"; import { HeaderLogs } from "@/Pages/Docker/ContainerDetails/Components/HeaderLogs"; import { RowLog } from "@/Pages/Docker/ContainerDetails/Components/RowLog"; @@ -32,6 +32,7 @@ import { flattenDockerLogs } from "@/Utils/MonitorUtils"; interface TabLogsProps { monitorId?: string; containerName?: string; + enabled: boolean; } const SCROLL_PIN_THRESHOLD_PX = 8; @@ -45,7 +46,7 @@ const fetchLogs = async (url: string, params: { before?: string; after?: string return res.data.data; }; -export const TabLogs = ({ monitorId, containerName }: TabLogsProps) => { +export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) => { const theme = useTheme(); const { t } = useTranslation(); const { toastError } = useToast(); @@ -58,7 +59,7 @@ export const TabLogs = ({ monitorId, containerName }: TabLogsProps) => { const [lastUpdatedAt, setLastUpdatedAt] = useState(null); const logsUrl = - monitorId && containerName + enabled && monitorId && containerName ? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs` : null; @@ -167,6 +168,17 @@ export const TabLogs = ({ monitorId, containerName }: TabLogsProps) => { } }, [logsUrl, oldestCursor, loadingOlder, t, toastError]); + if (!enabled) { + return ( + + ); + } + return ( { )} diff --git a/client/src/locales/en.json b/client/src/locales/en.json index b3aa6467c..06a0ff8e5 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -976,7 +976,12 @@ "loadingOlder": "Loading...", "noOlder": "Beginning of retained logs", "loadError": "Could not load logs", - "loadOlderError": "Could not load older logs" + "loadOlderError": "Could not load older logs", + "disabled": { + "title": "Log collection is off for this host", + "description": "Turn on container logs in the monitor settings to start collecting.", + "action": "Configure monitor" + } } }, "host": { From bd7b6038f56fd95a906358ec46311c6722892bc1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 14:36:26 -0700 Subject: [PATCH 22/23] add empty view for no logs --- client/src/Pages/Docker/ContainerDetails/TabLogs.tsx | 9 +++++++++ client/src/locales/en.json | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx index 8839d553b..e7e29207d 100644 --- a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx +++ b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx @@ -179,6 +179,15 @@ export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) => ); } + if (lastUpdatedAt !== null && rows.length === 0) { + return ( + + ); + } + return ( Date: Fri, 4 Sep 2026 14:41:10 -0700 Subject: [PATCH 23/23] add docker redirect --- client/src/Pages/CreateMonitor/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index afdc9a54c..3658276cd 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -376,6 +376,8 @@ const CreateMonitorPage = () => { navigate("/pagespeed"); } else if (pageType === "hardware") { navigate("/infrastructure"); + } else if (pageType === "docker") { + navigate("/docker"); } else { navigate("/uptime"); }