From c13f26a4c31f124cff85eed88944255927e9d3e3 Mon Sep 17 00:00:00 2001 From: signalgridco Date: Tue, 1 Sep 2026 23:17:11 +0200 Subject: [PATCH 01/89] feat: add Signalgrid notification provider --- .../src/Pages/Notifications/create/index.tsx | 26 +++ client/src/Types/Notification.ts | 1 + client/src/Validation/notifications.ts | 7 + client/src/locales/en.json | 9 + server/openapi/routes/notification.ts | 4 + .../api/validation/notificationValidation.ts | 7 + server/src/config/services.shared.ts | 3 + .../notifications/notification.model.ts | 1 + .../domain/notifications/notification.type.ts | 1 + .../notifications/providers/signalgrid.ts | 123 ++++++++++++++ .../notifications/signalgridProvider.test.ts | 155 ++++++++++++++++++ 11 files changed, 337 insertions(+) create mode 100644 server/src/domain/notifications/providers/signalgrid.ts create mode 100644 server/test/unit/providers/notifications/signalgridProvider.test.ts diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 8063f16b6..c4f339bf1 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -126,6 +126,7 @@ const NotificationsCreatePage = () => { {watchedType !== "matrix" && watchedType !== "telegram" && watchedType !== "pushover" && + watchedType !== "signalgrid" && watchedType !== "twilio" && watchedType !== "ntfy" && ( { } /> )} + {watchedType === "signalgrid" && ( + + + + + + } + /> + )} {watchedType === "twilio" && ( ( "teams", "telegram", "pushover", + "signalgrid", "twilio", "ntfy", ] as NotificationChannel[], diff --git a/server/src/domain/notifications/notification.type.ts b/server/src/domain/notifications/notification.type.ts index a45d4c4f8..628633d56 100644 --- a/server/src/domain/notifications/notification.type.ts +++ b/server/src/domain/notifications/notification.type.ts @@ -9,6 +9,7 @@ export const NotificationChannels = [ "teams", "telegram", "pushover", + "signalgrid", "twilio", "ntfy", ] as const; diff --git a/server/src/domain/notifications/providers/signalgrid.ts b/server/src/domain/notifications/providers/signalgrid.ts new file mode 100644 index 000000000..fdd930724 --- /dev/null +++ b/server/src/domain/notifications/providers/signalgrid.ts @@ -0,0 +1,123 @@ +const SERVICE_NAME = "SignalgridProvider"; +import type { Notification, NotificationMessage, NotificationSeverity } from "@/domain/notifications/notification.type.js"; +import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; +import { getTestMessage } from "@/domain/notifications/providers/utils.js"; +import got from "got"; + +export class SignalgridProvider extends NotificationProvider { + async sendTestAlert(notification: Partial): Promise { + if (!notification.address || !notification.accessToken) { + return false; + } + + try { + await got.post("https://api.signalgrid.co/v1/push", { + form: { + client_key: notification.accessToken, + channel: notification.address, + title: "Checkmate Test Notification", + body: getTestMessage(), + type: "INFO", + critical: false, + }, + ...this.gotRequestOptions(), + }); + return true; + } catch (error) { + const errMsg = error instanceof Error ? error.message : "unknown error"; + const errStack = error instanceof Error ? error.stack : undefined; + this.logger.warn({ + message: "Signalgrid test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + stack: errStack, + details: { error: errMsg }, + }); + return false; + } + } + + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + if (!notification.address || !notification.accessToken) { + return false; + } + + try { + await got.post("https://api.signalgrid.co/v1/push", { + form: { + client_key: notification.accessToken, + channel: notification.address, + title: message.content.title, + body: this.buildSignalgridText(message), + type: this.mapSeverity(message.severity), + critical: false, + }, + ...this.gotRequestOptions(), + }); + + this.logger.info({ + message: "Signalgrid notification sent", + service: SERVICE_NAME, + method: "sendMessage", + }); + return true; + } catch (error) { + const errMsg = error instanceof Error ? error.message : "unknown error"; + const errStack = error instanceof Error ? error.stack : undefined; + this.logger.warn({ + message: "Signalgrid alert failed", + service: SERVICE_NAME, + method: "sendMessage", + stack: errStack, + details: { error: errMsg }, + }); + return false; + } + } + + private mapSeverity(severity: NotificationSeverity): "CRIT" | "WARN" | "INFO" | "SUCCESS" { + switch (severity) { + case "critical": + return "CRIT"; + case "warning": + return "WARN"; + case "success": + return "SUCCESS"; + default: + return "INFO"; + } + } + + private buildSignalgridText(message: NotificationMessage): string { + const lines: string[] = []; + + lines.push(message.content.summary); + lines.push(""); + lines.push("Monitor Details:"); + lines.push(`• Name: ${message.monitor.name}`); + lines.push(`• URL: ${message.monitor.url}`); + lines.push(`• Type: ${message.monitor.type}`); + lines.push(`• Status: ${message.monitor.status}`); + + if (message.content.details && message.content.details.length > 0) { + lines.push(""); + lines.push("Additional Information:"); + message.content.details.forEach((detail) => lines.push(`• ${detail}`)); + } + + if (message.content.thresholds && message.content.thresholds.length > 0) { + lines.push(""); + lines.push("Threshold Breaches:"); + message.content.thresholds.forEach((breach) => { + lines.push(`• ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`); + }); + } + + if (message.content.incident) { + lines.push(""); + lines.push(`View Incident: ${message.clientHost}/incidents/${message.monitor.id}`); + } + + return lines.join("\n"); + } +} diff --git a/server/test/unit/providers/notifications/signalgridProvider.test.ts b/server/test/unit/providers/notifications/signalgridProvider.test.ts new file mode 100644 index 000000000..8c40bb1d9 --- /dev/null +++ b/server/test/unit/providers/notifications/signalgridProvider.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts"; +import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts"; + +const mockGotPost = jest.fn().mockResolvedValue({}); +jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } })); + +const { SignalgridProvider } = await import("../../../../src/domain/notifications/providers/signalgrid.ts"); + +const createProvider = () => { + const logger = createMockLogger(); + return { provider: new SignalgridProvider(logger as any), logger }; +}; + +testNotificationProviderContract("SignalgridProvider", { + create: () => { + mockGotPost.mockResolvedValue({}); + return createProvider().provider; + }, + makeNotification: () => makeNotification(), +}); + +describe("SignalgridProvider", () => { + beforeEach(() => mockGotPost.mockReset().mockResolvedValue({})); + + describe("sendTestAlert", () => { + it("sends to Signalgrid API and returns true", async () => { + expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + "https://api.signalgrid.co/v1/push", + expect.objectContaining({ + form: expect.objectContaining({ + client_key: "token-abc", + channel: "https://hooks.example.com/webhook", + type: "INFO", + critical: false, + }), + }) + ); + }); + + it("returns false when address is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false); + }); + + it("returns false when accessToken is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeNotification({ accessToken: undefined }))).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert", details: { error: "fail" } })); + }); + + it("handles non-Error thrown values in sendTestAlert", async () => { + mockGotPost.mockRejectedValue("string error"); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined, details: { error: "unknown error" } })); + }); + }); + + describe("sendMessage", () => { + it("sends message and returns true", async () => { + const { provider } = createProvider(); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true); + const form = mockGotPost.mock.calls[0][1].form; + expect(form.body).toContain("Monitor Details:"); + expect(form.title).toBeDefined(); + expect(form.critical).toBe(false); + }); + + it("maps critical severity to CRIT", async () => { + const { provider } = createProvider(); + const message = makeMessage(); + message.severity = "critical"; + await provider.sendMessage(makeNotification() as any, message); + expect(mockGotPost.mock.calls[0][1].form.type).toBe("CRIT"); + }); + + it("maps warning severity to WARN", async () => { + const { provider } = createProvider(); + const message = makeMessage(); + message.severity = "warning"; + await provider.sendMessage(makeNotification() as any, message); + expect(mockGotPost.mock.calls[0][1].form.type).toBe("WARN"); + }); + + it("maps success severity to SUCCESS", async () => { + const { provider } = createProvider(); + const message = makeMessage(); + message.severity = "success"; + await provider.sendMessage(makeNotification() as any, message); + expect(mockGotPost.mock.calls[0][1].form.type).toBe("SUCCESS"); + }); + + it("maps info severity to INFO", async () => { + const { provider } = createProvider(); + const message = makeMessage(); + message.severity = "info"; + await provider.sendMessage(makeNotification() as any, message); + expect(mockGotPost.mock.calls[0][1].form.type).toBe("INFO"); + }); + + it("returns false when address is missing", async () => { + expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false); + }); + + it("returns false when accessToken is missing", async () => { + expect(await createProvider().provider.sendMessage(makeNotification({ accessToken: undefined }) as any, makeMessage())).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" })); + }); + + it("handles non-Error thrown values in sendMessage", async () => { + mockGotPost.mockRejectedValue(42); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ details: { error: "unknown error" } })); + }); + + it("includes thresholds in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds()); + expect(mockGotPost.mock.calls[0][1].form.body).toContain("CPU"); + }); + + it("includes incident link in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessageWithIncident()); + expect(mockGotPost.mock.calls[0][1].form.body).toContain("View Incident"); + }); + + it("omits optional sections when not present", async () => { + const { provider } = createProvider(); + const msg = makeMessage(); + msg.content.thresholds = undefined; + msg.content.details = undefined; + msg.content.incident = undefined; + await provider.sendMessage(makeNotification() as any, msg); + const text = mockGotPost.mock.calls[0][1].form.body; + expect(text).not.toContain("Threshold"); + expect(text).not.toContain("Additional"); + expect(text).not.toContain("View Incident"); + }); + }); +}); From 77974682fba6424e103d4c95b572723e02d6d09b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 2 Sep 2026 10:00:37 -0700 Subject: [PATCH 02/89] 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 03/89] 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 04/89] 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 05/89] 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 af465766c8e6ed2ebf9f4f2bf5bd4f29b7d907e6 Mon Sep 17 00:00:00 2001 From: eegrok <304507+eegrok@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:13:32 +1200 Subject: [PATCH 06/89] fix Start Time description to accurately reflect that the time zone is local time, not UTC --- client/src/locales/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index cbebadded..44eae42c1 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1217,7 +1217,7 @@ }, "startTime": { "title": "Start time", - "description": "Set the start time and duration for your maintenance window. All values are in UTC", + "description": "Set the start time and duration for your maintenance window. All values are in your local time zone.", "option": { "duration": { "label": "Duration" From db4689207aa6e74b4b66c5927d98ff8006651a03 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Tue, 1 Sep 2026 22:35:46 -0400 Subject: [PATCH 07/89] fix(client): make check error messages selectable and copyable Closes #3907. Selecting the text of a failed check's error message navigated away before the selection could be copied. The mouseup that ends a drag-select lands on the table row, which fires the row's onClick. Two changes: - DataTable ignores a row click when the user has an active text selection, so drag-selecting text inside any row no longer navigates. This applies to all eleven tables that pass onRowClick, not just checks. - The message column gets an explicit copy button, which is the more discoverable of the two fixes the reporter suggested. Added as a shared CopyButton in design-elements so other columns can reuse it. Note the row click on the uptime details checks table navigates to /checks/, but that route's parameter is :monitorId. A check id never matches a monitor, so the destination page renders empty - which is the "all this information disappears" in the report. Left alone here to keep this change reviewable; it needs its own decision about what that row should link to. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- .../Components/design-elements/CopyButton.tsx | 64 +++++++++++++++++++ .../src/Components/design-elements/Table.tsx | 14 +++- .../src/Components/design-elements/index.tsx | 1 + .../Pages/Checks/Components/ChecksTable.tsx | 21 +++++- .../Uptime/Details/Components/ChecksTable.tsx | 21 +++++- client/src/locales/en.json | 4 ++ 6 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 client/src/Components/design-elements/CopyButton.tsx diff --git a/client/src/Components/design-elements/CopyButton.tsx b/client/src/Components/design-elements/CopyButton.tsx new file mode 100644 index 000000000..caa317598 --- /dev/null +++ b/client/src/Components/design-elements/CopyButton.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import IconButton from "@mui/material/IconButton"; +import Tooltip from "@mui/material/Tooltip"; +import { useTheme } from "@mui/material/styles"; +import { Check, Copy } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useToast } from "@/Hooks/UseToast"; + +const RESET_DELAY_MS = 1500; + +/** + * Copies `value` to the clipboard. Shows a tick for a moment so the click is + * acknowledged without moving the user anywhere. + */ +const CopyButton = ({ value, label }: { value: string; label?: string }) => { + const theme = useTheme(); + const { t } = useTranslation(); + const { toastError } = useToast(); + const [copied, setCopied] = useState(false); + + const handleCopy = async (e: React.MouseEvent) => { + // The button usually sits inside a clickable row. + e.stopPropagation(); + + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), RESET_DELAY_MS); + } catch { + toastError(t("common.copyFailed")); + } + }; + + const title = copied ? t("common.copied") : (label ?? t("common.copy")); + + return ( + + + {copied ? ( + + ) : ( + + )} + + + ); +}; + +export default CopyButton; diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx index df0d52b16..c7bdad9f8 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -79,6 +79,14 @@ export function DataTable< }; const isSmall = useMediaQuery(theme.breakpoints.down("md")); + + // Selecting text inside a row ends in a mouseup on that row, which would + // otherwise navigate away and take the text with it. Ignore the click when + // the user has actually selected something. + const hasTextSelection = () => { + const selection = window.getSelection(); + return Boolean(selection && !selection.isCollapsed && selection.toString().trim()); + }; const isInteractive = Boolean(onRowClick) || expandableRows; if (data.length === 0 || headers.length === 0) { @@ -100,7 +108,10 @@ export function DataTable< keys.push(key); return ( (onRowClick ? onRowClick(row) : null)} + onClick={() => { + if (hasTextSelection()) return; + if (onRowClick) onRowClick(row); + }} spacing={theme.spacing(LAYOUT.XS)} sx={{ borderStyle: "solid", @@ -247,6 +258,7 @@ export function DataTable< ...(getRowSx?.(row) as object), }} onClick={() => { + if (hasTextSelection()) return; if (expandableRows) handleExpand(row); else if (onRowClick) onRowClick(row); }} diff --git a/client/src/Components/design-elements/index.tsx b/client/src/Components/design-elements/index.tsx index d7d38d014..e35d95823 100644 --- a/client/src/Components/design-elements/index.tsx +++ b/client/src/Components/design-elements/index.tsx @@ -28,3 +28,4 @@ export * from "./Avatar"; export * from "./StatusCodeLabel"; export * from "./StrategyBadge"; export * from "./StepProgress"; +export { default as CopyButton } from "./CopyButton"; diff --git a/client/src/Pages/Checks/Components/ChecksTable.tsx b/client/src/Pages/Checks/Components/ChecksTable.tsx index 31737f4bb..0e75116dc 100644 --- a/client/src/Pages/Checks/Components/ChecksTable.tsx +++ b/client/src/Pages/Checks/Components/ChecksTable.tsx @@ -3,8 +3,12 @@ import { Pagination, StatusLabel, StatusCodeLabel, + CopyButton, } from "@/Components/design-elements"; import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import { useTheme } from "@mui/material/styles"; +import { LAYOUT } from "@/Utils/Theme/constants"; import type { Header } from "@/Components/design-elements/Table"; import type { Monitor } from "@/Types/Monitor"; import { useTranslation } from "react-i18next"; @@ -31,6 +35,7 @@ export const ChecksTable = ({ setRowsPerPage: (rowsPerPage: number) => void; }) => { const { t } = useTranslation(); + const theme = useTheme(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const getHeaders = () => { @@ -79,7 +84,21 @@ export const ChecksTable = ({ id: "message", content: t("common.table.headers.message"), render: (row) => { - return row.message || "N/A"; + if (!row.message) return "N/A"; + return ( + + {row.message} + + + ); }, }, ]; diff --git a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx index b100c28c4..91b4ce588 100644 --- a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx @@ -3,8 +3,12 @@ import { Pagination, StatusLabel, StatusCodeLabel, + CopyButton, } from "@/Components/design-elements"; import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import { useTheme } from "@mui/material/styles"; +import { LAYOUT } from "@/Utils/Theme/constants"; import type { Header } from "@/Components/design-elements"; import type { Check } from "@/Types/Check"; @@ -31,6 +35,7 @@ export const ChecksTable = ({ }) => { const navigate = useNavigate(); const { t } = useTranslation(); + const theme = useTheme(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const headers: Header[] = [ @@ -52,7 +57,21 @@ export const ChecksTable = ({ id: "message", content: t("common.table.headers.message"), render: (row) => { - return row.message || "N/A"; + if (!row.message) return "N/A"; + return ( + + {row.message} + + + ); }, }, { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index cbebadded..9b193ef1f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -14,6 +14,10 @@ } }, "appName": "Checkmate", + "copy": "Copy", + "copied": "Copied", + "copyMessage": "Copy message", + "copyFailed": "Could not copy to clipboard", "statusCode": { "down": "Down", "networkError": { From 84a1b994a147197b7e0f5e4e806c142a0c9d3f91 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Tue, 1 Sep 2026 22:40:29 -0400 Subject: [PATCH 08/89] fix(client): address review feedback on the copy-message change Four issues from code review: - The selection guard read the document-wide selection, so a leftover selection anywhere on the page could swallow the first click on any row. It now checks that the selection is inside the row that was clicked, with the event threaded through to both call sites. - CopyButton's reset timer was never cleared, so it fired against an unmounted component when a row went away on pagination, and a rapid second click could not cancel the pending reset. Held in a ref and cleared on unmount and on re-copy. - navigator.clipboard only exists on secure origins, and the README documents reaching Checkmate over plain HTTP on a LAN address. There the API is undefined, so every click showed "could not copy" with no way forward. Falls back to a hidden textarea. - The two message cells were byte-identical, so they are now one shared CopyableText component. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- .../Components/design-elements/CopyButton.tsx | 36 ++++++++++++++-- .../design-elements/CopyableText.tsx | 41 +++++++++++++++++++ .../src/Components/design-elements/Table.tsx | 25 +++++++---- .../src/Components/design-elements/index.tsx | 1 + .../Pages/Checks/Components/ChecksTable.tsx | 22 +--------- .../Uptime/Details/Components/ChecksTable.tsx | 22 +--------- 6 files changed, 97 insertions(+), 50 deletions(-) create mode 100644 client/src/Components/design-elements/CopyableText.tsx diff --git a/client/src/Components/design-elements/CopyButton.tsx b/client/src/Components/design-elements/CopyButton.tsx index caa317598..5d45b9739 100644 --- a/client/src/Components/design-elements/CopyButton.tsx +++ b/client/src/Components/design-elements/CopyButton.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import IconButton from "@mui/material/IconButton"; import Tooltip from "@mui/material/Tooltip"; import { useTheme } from "@mui/material/styles"; @@ -8,6 +8,31 @@ import { useToast } from "@/Hooks/UseToast"; const RESET_DELAY_MS = 1500; +/** + * `navigator.clipboard` only exists on secure origins, and Checkmate is often + * self-hosted over plain HTTP on a LAN address. Fall back to a hidden textarea + * there rather than leaving the button permanently broken. + */ +const copyText = async (value: string) => { + if (navigator.clipboard?.writeText) { + return navigator.clipboard.writeText(value); + } + + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + + try { + if (!document.execCommand("copy")) throw new Error("Copy command was rejected"); + } finally { + document.body.removeChild(textarea); + } +}; + /** * Copies `value` to the clipboard. Shows a tick for a moment so the click is * acknowledged without moving the user anywhere. @@ -17,15 +42,20 @@ const CopyButton = ({ value, label }: { value: string; label?: string }) => { const { t } = useTranslation(); const { toastError } = useToast(); const [copied, setCopied] = useState(false); + const resetTimer = useRef>(undefined); + + // These rows unmount on pagination, so the tick timer has to be cancelled. + useEffect(() => () => clearTimeout(resetTimer.current), []); const handleCopy = async (e: React.MouseEvent) => { // The button usually sits inside a clickable row. e.stopPropagation(); try { - await navigator.clipboard.writeText(value); + await copyText(value); setCopied(true); - setTimeout(() => setCopied(false), RESET_DELAY_MS); + clearTimeout(resetTimer.current); + resetTimer.current = setTimeout(() => setCopied(false), RESET_DELAY_MS); } catch { toastError(t("common.copyFailed")); } diff --git a/client/src/Components/design-elements/CopyableText.tsx b/client/src/Components/design-elements/CopyableText.tsx new file mode 100644 index 000000000..5d09e307b --- /dev/null +++ b/client/src/Components/design-elements/CopyableText.tsx @@ -0,0 +1,41 @@ +import Stack from "@mui/material/Stack"; +import { useTheme } from "@mui/material/styles"; +import { useTranslation } from "react-i18next"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import CopyButton from "./CopyButton"; + +/** + * Text alongside a copy button, for table cells holding values worth lifting + * out - error messages in particular. + */ +const CopyableText = ({ + value, + fallback = "N/A", + copyLabel, +}: { + value?: string | null; + fallback?: string; + copyLabel?: string; +}) => { + const theme = useTheme(); + const { t } = useTranslation(); + + if (!value) return <>{fallback}; + + return ( + + {value} + + + ); +}; + +export default CopyableText; diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx index c7bdad9f8..8650f2bc6 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -82,10 +82,21 @@ export function DataTable< // Selecting text inside a row ends in a mouseup on that row, which would // otherwise navigate away and take the text with it. Ignore the click when - // the user has actually selected something. - const hasTextSelection = () => { + // the selection is inside the row that was clicked - a leftover selection + // elsewhere on the page must not swallow a legitimate click. + const hasTextSelectionWithin = (element: Element) => { const selection = window.getSelection(); - return Boolean(selection && !selection.isCollapsed && selection.toString().trim()); + if (!selection || selection.isCollapsed || !selection.toString().trim()) { + return false; + } + return ( + selection.anchorNode !== null && + element.contains( + selection.anchorNode.nodeType === Node.TEXT_NODE + ? selection.anchorNode.parentNode + : selection.anchorNode + ) + ); }; const isInteractive = Boolean(onRowClick) || expandableRows; @@ -108,8 +119,8 @@ export function DataTable< keys.push(key); return ( { - if (hasTextSelection()) return; + onClick={(e) => { + if (hasTextSelectionWithin(e.currentTarget)) return; if (onRowClick) onRowClick(row); }} spacing={theme.spacing(LAYOUT.XS)} @@ -257,8 +268,8 @@ export function DataTable< cursor: isInteractive ? "pointer" : "default", ...(getRowSx?.(row) as object), }} - onClick={() => { - if (hasTextSelection()) return; + onClick={(e) => { + if (hasTextSelectionWithin(e.currentTarget)) return; if (expandableRows) handleExpand(row); else if (onRowClick) onRowClick(row); }} diff --git a/client/src/Components/design-elements/index.tsx b/client/src/Components/design-elements/index.tsx index e35d95823..e1c1c3965 100644 --- a/client/src/Components/design-elements/index.tsx +++ b/client/src/Components/design-elements/index.tsx @@ -29,3 +29,4 @@ export * from "./StatusCodeLabel"; export * from "./StrategyBadge"; export * from "./StepProgress"; export { default as CopyButton } from "./CopyButton"; +export { default as CopyableText } from "./CopyableText"; diff --git a/client/src/Pages/Checks/Components/ChecksTable.tsx b/client/src/Pages/Checks/Components/ChecksTable.tsx index 0e75116dc..4d5eebbdf 100644 --- a/client/src/Pages/Checks/Components/ChecksTable.tsx +++ b/client/src/Pages/Checks/Components/ChecksTable.tsx @@ -3,12 +3,9 @@ import { Pagination, StatusLabel, StatusCodeLabel, - CopyButton, + CopyableText, } from "@/Components/design-elements"; import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import { useTheme } from "@mui/material/styles"; -import { LAYOUT } from "@/Utils/Theme/constants"; import type { Header } from "@/Components/design-elements/Table"; import type { Monitor } from "@/Types/Monitor"; import { useTranslation } from "react-i18next"; @@ -35,7 +32,6 @@ export const ChecksTable = ({ setRowsPerPage: (rowsPerPage: number) => void; }) => { const { t } = useTranslation(); - const theme = useTheme(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const getHeaders = () => { @@ -84,21 +80,7 @@ export const ChecksTable = ({ id: "message", content: t("common.table.headers.message"), render: (row) => { - if (!row.message) return "N/A"; - return ( - - {row.message} - - - ); + return ; }, }, ]; diff --git a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx index 91b4ce588..ae1d87d45 100644 --- a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx @@ -3,12 +3,9 @@ import { Pagination, StatusLabel, StatusCodeLabel, - CopyButton, + CopyableText, } from "@/Components/design-elements"; import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import { useTheme } from "@mui/material/styles"; -import { LAYOUT } from "@/Utils/Theme/constants"; import type { Header } from "@/Components/design-elements"; import type { Check } from "@/Types/Check"; @@ -35,7 +32,6 @@ export const ChecksTable = ({ }) => { const navigate = useNavigate(); const { t } = useTranslation(); - const theme = useTheme(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const headers: Header[] = [ @@ -57,21 +53,7 @@ export const ChecksTable = ({ id: "message", content: t("common.table.headers.message"), render: (row) => { - if (!row.message) return "N/A"; - return ( - - {row.message} - - - ); + return ; }, }, { From 9c796edbde33b7c2ec71dc86875f9ba2639ba86d Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Wed, 2 Sep 2026 08:34:52 -0400 Subject: [PATCH 09/89] refactor(client): drop the copy button, keep the selection fix The selection guard alone closes #3907: drag-selecting a check's error message no longer navigates away, so the text can be copied. The copy button was a second, larger answer to the same problem, and it brought a reusable component, an HTTP clipboard fallback, four translation keys and a timer to maintain. Reverting it leaves the fix at ~29 lines in one file, and keeps the behaviour change to the thing that was actually broken. CopyButton is worth having for monitor URLs, tokens and incident ids, but it should land on its own merits rather than inside a bug fix. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- .../Components/design-elements/CopyButton.tsx | 94 ------------------- .../design-elements/CopyableText.tsx | 41 -------- .../src/Components/design-elements/index.tsx | 2 - .../Pages/Checks/Components/ChecksTable.tsx | 3 +- .../Uptime/Details/Components/ChecksTable.tsx | 3 +- client/src/locales/en.json | 4 - 6 files changed, 2 insertions(+), 145 deletions(-) delete mode 100644 client/src/Components/design-elements/CopyButton.tsx delete mode 100644 client/src/Components/design-elements/CopyableText.tsx diff --git a/client/src/Components/design-elements/CopyButton.tsx b/client/src/Components/design-elements/CopyButton.tsx deleted file mode 100644 index 5d45b9739..000000000 --- a/client/src/Components/design-elements/CopyButton.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import IconButton from "@mui/material/IconButton"; -import Tooltip from "@mui/material/Tooltip"; -import { useTheme } from "@mui/material/styles"; -import { Check, Copy } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { useToast } from "@/Hooks/UseToast"; - -const RESET_DELAY_MS = 1500; - -/** - * `navigator.clipboard` only exists on secure origins, and Checkmate is often - * self-hosted over plain HTTP on a LAN address. Fall back to a hidden textarea - * there rather than leaving the button permanently broken. - */ -const copyText = async (value: string) => { - if (navigator.clipboard?.writeText) { - return navigator.clipboard.writeText(value); - } - - const textarea = document.createElement("textarea"); - textarea.value = value; - textarea.setAttribute("readonly", ""); - textarea.style.position = "fixed"; - textarea.style.opacity = "0"; - document.body.appendChild(textarea); - textarea.select(); - - try { - if (!document.execCommand("copy")) throw new Error("Copy command was rejected"); - } finally { - document.body.removeChild(textarea); - } -}; - -/** - * Copies `value` to the clipboard. Shows a tick for a moment so the click is - * acknowledged without moving the user anywhere. - */ -const CopyButton = ({ value, label }: { value: string; label?: string }) => { - const theme = useTheme(); - const { t } = useTranslation(); - const { toastError } = useToast(); - const [copied, setCopied] = useState(false); - const resetTimer = useRef>(undefined); - - // These rows unmount on pagination, so the tick timer has to be cancelled. - useEffect(() => () => clearTimeout(resetTimer.current), []); - - const handleCopy = async (e: React.MouseEvent) => { - // The button usually sits inside a clickable row. - e.stopPropagation(); - - try { - await copyText(value); - setCopied(true); - clearTimeout(resetTimer.current); - resetTimer.current = setTimeout(() => setCopied(false), RESET_DELAY_MS); - } catch { - toastError(t("common.copyFailed")); - } - }; - - const title = copied ? t("common.copied") : (label ?? t("common.copy")); - - return ( - - - {copied ? ( - - ) : ( - - )} - - - ); -}; - -export default CopyButton; diff --git a/client/src/Components/design-elements/CopyableText.tsx b/client/src/Components/design-elements/CopyableText.tsx deleted file mode 100644 index 5d09e307b..000000000 --- a/client/src/Components/design-elements/CopyableText.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import Stack from "@mui/material/Stack"; -import { useTheme } from "@mui/material/styles"; -import { useTranslation } from "react-i18next"; -import { LAYOUT } from "@/Utils/Theme/constants"; -import CopyButton from "./CopyButton"; - -/** - * Text alongside a copy button, for table cells holding values worth lifting - * out - error messages in particular. - */ -const CopyableText = ({ - value, - fallback = "N/A", - copyLabel, -}: { - value?: string | null; - fallback?: string; - copyLabel?: string; -}) => { - const theme = useTheme(); - const { t } = useTranslation(); - - if (!value) return <>{fallback}; - - return ( - - {value} - - - ); -}; - -export default CopyableText; diff --git a/client/src/Components/design-elements/index.tsx b/client/src/Components/design-elements/index.tsx index e1c1c3965..d7d38d014 100644 --- a/client/src/Components/design-elements/index.tsx +++ b/client/src/Components/design-elements/index.tsx @@ -28,5 +28,3 @@ export * from "./Avatar"; export * from "./StatusCodeLabel"; export * from "./StrategyBadge"; export * from "./StepProgress"; -export { default as CopyButton } from "./CopyButton"; -export { default as CopyableText } from "./CopyableText"; diff --git a/client/src/Pages/Checks/Components/ChecksTable.tsx b/client/src/Pages/Checks/Components/ChecksTable.tsx index 4d5eebbdf..31737f4bb 100644 --- a/client/src/Pages/Checks/Components/ChecksTable.tsx +++ b/client/src/Pages/Checks/Components/ChecksTable.tsx @@ -3,7 +3,6 @@ import { Pagination, StatusLabel, StatusCodeLabel, - CopyableText, } from "@/Components/design-elements"; import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements/Table"; @@ -80,7 +79,7 @@ export const ChecksTable = ({ id: "message", content: t("common.table.headers.message"), render: (row) => { - return ; + return row.message || "N/A"; }, }, ]; diff --git a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx index ae1d87d45..b100c28c4 100644 --- a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx @@ -3,7 +3,6 @@ import { Pagination, StatusLabel, StatusCodeLabel, - CopyableText, } from "@/Components/design-elements"; import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements"; @@ -53,7 +52,7 @@ export const ChecksTable = ({ id: "message", content: t("common.table.headers.message"), render: (row) => { - return ; + return row.message || "N/A"; }, }, { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 9b193ef1f..cbebadded 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -14,10 +14,6 @@ } }, "appName": "Checkmate", - "copy": "Copy", - "copied": "Copied", - "copyMessage": "Copy message", - "copyFailed": "Could not copy to clipboard", "statusCode": { "down": "Down", "networkError": { From e69a2f05af46792c21c1e376ac1195e691375317 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 3 Sep 2026 00:45:40 -0400 Subject: [PATCH 10/89] feat(client): define the neutral palette and switch to Geist The palette declared a brand green and six chart hues but never defined the neutrals, so MUI's defaults filled the gap: body text at 60% black, dividers at 12%, and background.default equal to background.paper. That is the "washed out" everything sat on - table cells render through text.secondary, and cards had no ground to sit on. - Opaque neutral ramp, biased slightly green so it sits with the brand. background.default is now off-white so paper separates from the page. - Semantic success/error/warning derived from the brand instead of Material's stock red/green/orange. Only `main` is set: `light` is consumed as a saturated colour by status dots, histogram bars and heatmap cells, so MUI must keep deriving it. - Dark mode gets the same treatment; it defined background and secondary but its text and divider were still MUI defaults. - Geist Variable, self-hosted via @fontsource-variable so air-gapped installs keep the typeface. Base size stays at 13px. - h1 700 / h2 600 against 400 body, and table headers 500 -> 600. Two weights was why everything read at the same visual weight. All contrast pairs pass WCAG AA in both themes (lowest 5.98:1). No component markup changed. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/package-lock.json | 10 ++++ client/package.json | 1 + .../src/Components/design-elements/Table.tsx | 2 +- client/src/Utils/Theme/Palette.ts | 54 +++++++++++++++++++ client/src/Utils/Theme/Theme.ts | 8 +-- client/src/main.tsx | 2 + 6 files changed, 73 insertions(+), 4 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 6cee968e3..629678180 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -11,6 +11,7 @@ "@csstools/css-tokenizer": "^3.0.4", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", + "@fontsource-variable/geist": "^5.3.0", "@hello-pangea/dnd": "^18.0.0", "@hookform/resolvers": "5.2.2", "@mui/lab": "7.0.0", @@ -950,6 +951,15 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fontsource-variable/geist": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz", + "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "license": "BSD-3-Clause" diff --git a/client/package.json b/client/package.json index ce2e481ba..a26499805 100644 --- a/client/package.json +++ b/client/package.json @@ -21,6 +21,7 @@ "@csstools/css-tokenizer": "^3.0.4", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", + "@fontsource-variable/geist": "^5.3.0", "@hello-pangea/dnd": "^18.0.0", "@hookform/resolvers": "5.2.2", "@mui/lab": "7.0.0", diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx index df0d52b16..9ef7dcee8 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -197,7 +197,7 @@ export function DataTable< "& :is(th)": { backgroundColor: theme.palette.background.paper, color: theme.palette.text.secondary, - fontWeight: 500, + fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.08em", padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`, diff --git a/client/src/Utils/Theme/Palette.ts b/client/src/Utils/Theme/Palette.ts index 0d9168f8a..bfb9dda5d 100644 --- a/client/src/Utils/Theme/Palette.ts +++ b/client/src/Utils/Theme/Palette.ts @@ -18,6 +18,23 @@ export const colors = { gray700: "#313131", gray900: "#151518", gray850: "#1c1c21", + // Neutral ramp, biased slightly green so it sits with the brand rather than + // against it. Opaque values - alpha-black over white reads washed out. + ink900: "#101917", + ink600: "#55635C", + ink400: "#7C8983", + surface50: "#F6F8F7", + rule200: "#D9E1DD", + // Semantic colours derived from the brand rather than Material's defaults + successMain: "#0D6049", + errorMain: "#A02B24", + warningMain: "#8C5806", + inkDark900: "#ECEFED", + inkDark600: "#A3ADA8", + ruleDark: "#2C3532", + successDark: "#4BC49E", + errorDark: "#E8837A", + warningDark: "#E0A458", brandGreen: "#13715B", brandGreenLight: "#4DAF94", green200: "#ECF7F2", @@ -47,6 +64,28 @@ export const lightPalette = { secondary: { main: colors.gray200, }, + background: { + default: colors.surface50, + paper: "#FFFFFF", + }, + text: { + primary: colors.ink900, + secondary: colors.ink600, + disabled: colors.ink400, + }, + divider: colors.rule200, + // Only `main` is set. MUI derives `light`/`dark` from it, and `light` is + // consumed as a saturated colour - status dots, histogram bars, heatmap + // cells - so it must not be overridden with a pale tint. + success: { + main: colors.successMain, + }, + error: { + main: colors.errorMain, + }, + warning: { + main: colors.warningMain, + }, sidebar: { accent: colors.brandGreen, }, @@ -81,6 +120,21 @@ export const darkPalette = { default: colors.gray900, paper: colors.gray850, }, + text: { + primary: colors.inkDark900, + secondary: colors.inkDark600, + disabled: colors.ink400, + }, + divider: colors.ruleDark, + success: { + main: colors.successDark, + }, + error: { + main: colors.errorDark, + }, + warning: { + main: colors.warningDark, + }, sidebar: { accent: colors.brandGreenLight, }, diff --git a/client/src/Utils/Theme/Theme.ts b/client/src/Utils/Theme/Theme.ts index c63929a60..a0b1d75be 100644 --- a/client/src/Utils/Theme/Theme.ts +++ b/client/src/Utils/Theme/Theme.ts @@ -57,7 +57,7 @@ export type PaletteKey = { [K in keyof Theme["palette"]]: Theme["palette"][K] extends { main: any } ? K : never; }[keyof Theme["palette"]]; -const fontFamilyPrimary = "system-ui, sans-serif"; +const fontFamilyPrimary = '"Geist Variable", system-ui, sans-serif'; const fontFamilyMonospace = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace'; const shadow = @@ -85,11 +85,13 @@ export const theme = (mode: string, palette: any) => fontSize: typographyLevels.base, h1: { fontSize: typographyLevels.xxl, - fontWeight: 500, + fontWeight: 700, + letterSpacing: "-0.015em", }, h2: { fontSize: typographyLevels.l, - fontWeight: 400, + fontWeight: 600, + letterSpacing: "-0.01em", }, body1: { fontSize: typographyLevels.m, diff --git a/client/src/main.tsx b/client/src/main.tsx index 1f5d4d2ef..ca81c338c 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,5 +1,7 @@ import ReactDOM from "react-dom/client"; import App from "./App.jsx"; +// Self-hosted so the app keeps its typeface on air-gapped installs +import "@fontsource-variable/geist"; import "./index.css"; import { BrowserRouter as Router } from "react-router-dom"; import { Provider } from "react-redux"; From bdad9491703fa2b3a76dd4277b9a738269b41822 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 3 Sep 2026 00:48:02 -0400 Subject: [PATCH 11/89] fix(client): put the tab bar back on the 13px base Tabs.tsx hardcoded fontSize: 14, so the tab bar sat a pixel above the 13px base the rest of the chrome uses. Now typographyLevels.m, which also satisfies the no-hardcoded-literals rule. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Components/design-elements/Tabs.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/Components/design-elements/Tabs.tsx b/client/src/Components/design-elements/Tabs.tsx index 149c0bd93..ae9a1c512 100644 --- a/client/src/Components/design-elements/Tabs.tsx +++ b/client/src/Components/design-elements/Tabs.tsx @@ -1,6 +1,7 @@ import MuiTabs from "@mui/material/Tabs"; import type { TabsProps } from "@mui/material/Tabs"; import { useTheme } from "@mui/material/styles"; +import { typographyLevels } from "@/Utils/Theme/Palette"; interface CustomTabsProps extends TabsProps {} export const Tabs = (props: CustomTabsProps) => { @@ -38,7 +39,7 @@ export const Tab = (props: CustomTabProps) => { iconPosition="start" sx={{ textTransform: "none", - fontSize: 14, + fontSize: typographyLevels.m, fontWeight: 500, minHeight: 34, padding: theme.spacing(1, 0), From 1372e66749cd955e33e36364d3474e3d7c69535a Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 3 Sep 2026 00:52:41 -0400 Subject: [PATCH 12/89] fix(client): calm the stat box grid and firm up borders Three fixes, all fallout from giving `divider` a real value: - The graph-paper texture behind the status counts is drawn with `divider`. That used to be rgba(0,0,0,.12) and near-invisible; now it is opaque, so the grid jumped forward and read as the loudest thing on the page. It is texture, not a rule, so it now takes divider at 40%. - GradientBox called lighten() on background.paper to build its gradient. paper is white in light mode and lightening white is a no-op, so the gradient collapsed flat. Darkens slightly toward the page ground instead. - Borders were too faint at #D9E1DD (1.33:1 against paper). Now #C7D2CC (1.55:1), and dark mode #2C3532 -> #3A4642 (1.73:1), so tables and cards have a visible edge without turning into hard rules. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Components/design-elements/StatBox.tsx | 6 ++++-- client/src/Components/design-elements/StatusBox.tsx | 9 ++++++--- client/src/Utils/Theme/Palette.ts | 4 ++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/client/src/Components/design-elements/StatBox.tsx b/client/src/Components/design-elements/StatBox.tsx index 1543766fc..addbc7ee9 100644 --- a/client/src/Components/design-elements/StatBox.tsx +++ b/client/src/Components/design-elements/StatBox.tsx @@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import Box from "@mui/material/Box"; import { useTheme } from "@mui/material/styles"; -import { lighten } from "@mui/material/styles"; +import { darken, lighten } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import type { PaletteKey } from "@/Utils/Theme/Theme"; import { BaseBox, TooltipWithInfo } from "@/Components/design-elements"; @@ -19,7 +19,9 @@ export const GradientBox = ({ children, palette, sx, interactive }: GradientBox) const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isLight = theme.palette.mode === "light"; const paper = theme.palette.background.paper; - const paperStart = lighten(paper, isLight ? 0.06 : 0.08); + // In light mode `paper` is white, and lightening white is a no-op - the + // gradient collapses flat. Darken slightly towards the page ground instead. + const paperStart = isLight ? darken(paper, 0.02) : lighten(paper, 0.08); const paperEnd = paper; const bg = palette ? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)` diff --git a/client/src/Components/design-elements/StatusBox.tsx b/client/src/Components/design-elements/StatusBox.tsx index 017873eb9..191ed21d6 100644 --- a/client/src/Components/design-elements/StatusBox.tsx +++ b/client/src/Components/design-elements/StatusBox.tsx @@ -5,7 +5,7 @@ import { BaseBox } from "@/Components/design-elements"; import { useTranslation } from "react-i18next"; import type { SxProps } from "@mui/material"; -import { useTheme } from "@mui/material/styles"; +import { alpha, useTheme } from "@mui/material/styles"; type StatusBoxProps = React.PropsWithChildren<{ children: React.ReactNode; @@ -33,9 +33,12 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => { bottom={0} sx={{ pointerEvents: "none", + // Deliberately far fainter than `divider` - this is texture + // behind the number, not a rule, and it must never compete + // with the box's own border. backgroundImage: ` - linear-gradient(${theme.palette.divider} 1px, transparent 1px), - linear-gradient(90deg, ${theme.palette.divider} 1px, transparent 1px) + linear-gradient(${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px), + linear-gradient(90deg, ${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px) `, backgroundSize: "24px 24px", maskImage: diff --git a/client/src/Utils/Theme/Palette.ts b/client/src/Utils/Theme/Palette.ts index bfb9dda5d..a7f4b9a27 100644 --- a/client/src/Utils/Theme/Palette.ts +++ b/client/src/Utils/Theme/Palette.ts @@ -24,14 +24,14 @@ export const colors = { ink600: "#55635C", ink400: "#7C8983", surface50: "#F6F8F7", - rule200: "#D9E1DD", + rule200: "#C7D2CC", // Semantic colours derived from the brand rather than Material's defaults successMain: "#0D6049", errorMain: "#A02B24", warningMain: "#8C5806", inkDark900: "#ECEFED", inkDark600: "#A3ADA8", - ruleDark: "#2C3532", + ruleDark: "#3A4642", successDark: "#4BC49E", errorDark: "#E8837A", warningDark: "#E0A458", From 8582958ad5e2f55cbba06cbc9b845e8b094f3def Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 3 Sep 2026 00:56:29 -0400 Subject: [PATCH 13/89] fix(client): rebuild the stat box gradient from `main` The coloured stat box ran its gradient from `.light` to `.main`. With Material's stock warning that was #ff9800 -> #ed6c02, a bright orange. The new warning.main is a dark amber chosen so the "Paused" chip text clears 4.5:1, and MUI derives `light` from it, so the gradient became brown mud. The two roles were never compatible: `main` has to be dark enough for small text on white, and the same value was being used as a large filled background. The gradient now runs from lighten(main, 0.12) to main, which keeps the box in the status colour, restores the depth that the flat brown lost, and holds white contrastText at 4.64:1 or better at the lightest point for all four palettes. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Components/design-elements/StatBox.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/src/Components/design-elements/StatBox.tsx b/client/src/Components/design-elements/StatBox.tsx index addbc7ee9..9cdb9c024 100644 --- a/client/src/Components/design-elements/StatBox.tsx +++ b/client/src/Components/design-elements/StatBox.tsx @@ -23,8 +23,11 @@ export const GradientBox = ({ children, palette, sx, interactive }: GradientBox) // gradient collapses flat. Darken slightly towards the page ground instead. const paperStart = isLight ? darken(paper, 0.02) : lighten(paper, 0.08); const paperEnd = paper; + // The gradient carries white `contrastText`, so it runs between two shades + // dark enough to keep that legible. `light` is a mid-tone used for status + // dots and chart marks - too pale here - so lift from `main` instead. const bg = palette - ? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)` + ? `linear-gradient(135deg, ${lighten(theme.palette[palette].main, 0.12)} 0%, ${theme.palette[palette].main} 100%)` : `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`; return ( From 62fb17f64c80d3e923ae5288bc244bed3d01ae0f Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 3 Sep 2026 01:00:23 -0400 Subject: [PATCH 14/89] fix(client): match header button icons and toggle weight to the rest of the UI Two mismatches in the monitor detail header: - The action button icons rendered at the shared Icon default of 20px, while the sidebar clamps the same lucide icons to 16px in CSS. The buttons were therefore 25% larger than every other icon in the app, and taller than the cap height of the 13px label beside them. Now 16px at all eight call sites. Left at full opacity - the sidebar dims to 0.81 because nav is recessive, but these are active controls. - ToggleButton went to fontWeight 500 when selected while Button is a flat 400, so the time range group sat heavier than the button row above it. Weight now stays at 400 and the tinted fill plus text.primary ink carry the selected state; that is a 2.9x difference in ink against the unselected label, so selection is still obvious. It also stops the label reflowing as you switch range. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Components/inputs/ToggleButton.tsx | 5 +- .../monitors/HeaderMonitorControls.tsx | 56 +++++++++++++++++-- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/client/src/Components/inputs/ToggleButton.tsx b/client/src/Components/inputs/ToggleButton.tsx index a2b392357..467c232b6 100644 --- a/client/src/Components/inputs/ToggleButton.tsx +++ b/client/src/Components/inputs/ToggleButton.tsx @@ -33,8 +33,11 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => { }, "&.Mui-selected": { backgroundColor: selectedBg, + // Weight stays at 400 so the group sits level with Button, + // which it is routinely placed beside. The fill and the + // darker ink carry the selected state instead - a weight + // change also reflows the label width as you switch. color: theme.palette.text.primary, - fontWeight: 500, "&:hover": { backgroundColor: selectedHoverBg, }, diff --git a/client/src/Components/monitors/HeaderMonitorControls.tsx b/client/src/Components/monitors/HeaderMonitorControls.tsx index 7f6968f0b..a32a33298 100644 --- a/client/src/Components/monitors/HeaderMonitorControls.tsx +++ b/client/src/Components/monitors/HeaderMonitorControls.tsx @@ -64,7 +64,12 @@ export const HeaderMonitorControls = ({ variant="contained" color="secondary" loading={isPosting} - startIcon={} + startIcon={ + + } onClick={async () => { await post(`/notifications/test/all`, { monitorId: monitor.id }); }} @@ -77,7 +82,12 @@ export const HeaderMonitorControls = ({ @@ -144,12 +144,12 @@ export const MonitorListPage = ({ )} {children} ); diff --git a/client/src/Pages/Uptime/Monitors/index.tsx b/client/src/Pages/Uptime/Monitors/index.tsx index f7664c1b7..642d871d3 100644 --- a/client/src/Pages/Uptime/Monitors/index.tsx +++ b/client/src/Pages/Uptime/Monitors/index.tsx @@ -4,7 +4,7 @@ import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsT import { useMonitorListController } from "@/Hooks/useMonitorListController"; const UptimeMonitorsPage = () => { - const c = useMonitorListController({ + const monitorListController = useMonitorListController({ types: "selectable", checksLimit: 25, refreshInterval: 5000, @@ -17,25 +17,25 @@ const UptimeMonitorsPage = () => { headerKey="uptime" page="uptime" actionLink="/uptime/create" - controller={c} + controller={monitorListController} bulkActions > ); From f7e1793bac3b1eeead5a60cfe29ef7d78c7a97aa Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 3 Sep 2026 12:56:44 -0700 Subject: [PATCH 28/89] 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 29/89] 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 30/89] 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 31/89] 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 32/89] 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 33/89] 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 34/89] 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 35/89] 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 36/89] 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 37/89] 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 38/89] 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 39/89] 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 40/89] 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 41/89] 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 42/89] 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 43/89] 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 44/89] 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"); } From ce9dce48b322da0871a9d2c3cd4ac2830ec35a39 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 14:55:07 -0700 Subject: [PATCH 45/89] update docker permissions --- docs/hosting/checkmate-so/docker-compose.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/hosting/checkmate-so/docker-compose.yaml b/docs/hosting/checkmate-so/docker-compose.yaml index 96849fecb..f588db87a 100644 --- a/docs/hosting/checkmate-so/docker-compose.yaml +++ b/docs/hosting/checkmate-so/docker-compose.yaml @@ -24,6 +24,8 @@ services: checkmate-demo: image: ghcr.io/bluewave-labs/checkmate:master container_name: checkmate-demo + group_add: + - "989" restart: always env_file: - server-prod.env @@ -68,6 +70,8 @@ services: checkmate-staging: image: ghcr.io/bluewave-labs/checkmate:develop container_name: checkmate-staging + group_add: + - "989" restart: always env_file: - server-staging.env From 1492569b739888171d25ad33e9402dcaac371ae4 Mon Sep 17 00:00:00 2001 From: Gorkem Cetin <167266851+gorkem-bwl@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:10:05 +0300 Subject: [PATCH 46/89] Remove code owners from CODEOWNERS file --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 10bf888d9..8b1378917 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @ajhollid @mohicody @Br0wnHammer @Owaiseimdad @karenvicent @shanikauwu1 \ No newline at end of file + From 02b0daa37da4b7794afbd398d5b9a292fe1b5cc5 Mon Sep 17 00:00:00 2001 From: Gorkem Cetin <167266851+gorkem-bwl@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:11:51 +0300 Subject: [PATCH 47/89] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bb5185378..93fe5807c 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you! ## Contributing -We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers. +We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Malena](https://github.com/malenacaroline) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers. We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K+ stars and attracted 150+ contributors from around the globe. From 7862b3c6f9fc1f2283f8e78ce4f1e9205757e35e Mon Sep 17 00:00:00 2001 From: Gorkem Cetin <167266851+gorkem-bwl@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:14:17 +0300 Subject: [PATCH 48/89] Update image in README.md Replaced the old image with a new image in the README. --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 93fe5807c..de6068fb9 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,7 @@ [![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) - -image +image


From 2eeb088b618a1c83f08d8abf7f1451789d71e001 Mon Sep 17 00:00:00 2001 From: Ali Shan Ahmad Date: Sun, 6 Sep 2026 08:35:43 +0000 Subject: [PATCH 49/89] [FIX]: rbac by adding the missing isAllowed(["admin", "superadmin"]) authorization middleware to mutating API endpoints for maintenance windows, notifications, and monitor check history. --- server/src/api/routes/checkRoutes.ts | 2 +- .../src/api/routes/maintenanceWindowRoutes.ts | 7 +- server/src/api/routes/notificationRoutes.ts | 11 +-- server/test/unit/middleware/isAllowed.test.ts | 79 +++++++++++++++++++ 4 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 server/test/unit/middleware/isAllowed.test.ts diff --git a/server/src/api/routes/checkRoutes.ts b/server/src/api/routes/checkRoutes.ts index 97974f9df..99c6f8a07 100644 --- a/server/src/api/routes/checkRoutes.ts +++ b/server/src/api/routes/checkRoutes.ts @@ -9,6 +9,6 @@ export const createCheckRoutes = (checkController: ICheckController): Router => router.get("/team", checkController.getChecksByTeam); router.delete("/team", isAllowed(["admin", "superadmin"]), checkController.deleteChecksByTeamId); router.get("/:monitorId", checkController.getChecksByMonitor); - router.delete("/:monitorId", checkController.deleteChecks); + router.delete("/:monitorId", isAllowed(["admin", "superadmin"]), checkController.deleteChecks); return router; }; diff --git a/server/src/api/routes/maintenanceWindowRoutes.ts b/server/src/api/routes/maintenanceWindowRoutes.ts index 22c3fd94a..6796d1a72 100644 --- a/server/src/api/routes/maintenanceWindowRoutes.ts +++ b/server/src/api/routes/maintenanceWindowRoutes.ts @@ -1,15 +1,16 @@ import { IMaintenanceWindowController } from "@/api/controllers/maintenanceWindowController.js"; +import { isAllowed } from "@/api/middleware/isAllowed.js"; import { Router } from "express"; export const createMaintenanceWindowRoutes = (maintenanceWindowController: IMaintenanceWindowController): Router => { const router = Router(); - router.post("/", maintenanceWindowController.createMaintenanceWindows); + router.post("/", isAllowed(["admin", "superadmin"]), maintenanceWindowController.createMaintenanceWindows); router.get("/team/", maintenanceWindowController.getMaintenanceWindowsByTeamId); router.get("/monitor/:monitorId", maintenanceWindowController.getMaintenanceWindowsByMonitorId); router.get("/:id", maintenanceWindowController.getMaintenanceWindowById); - router.patch("/:id", maintenanceWindowController.editMaintenanceWindow); - router.delete("/:id", maintenanceWindowController.deleteMaintenanceWindow); + router.patch("/:id", isAllowed(["admin", "superadmin"]), maintenanceWindowController.editMaintenanceWindow); + router.delete("/:id", isAllowed(["admin", "superadmin"]), maintenanceWindowController.deleteMaintenanceWindow); return router; }; diff --git a/server/src/api/routes/notificationRoutes.ts b/server/src/api/routes/notificationRoutes.ts index 32ea1923c..e2d7bfc86 100644 --- a/server/src/api/routes/notificationRoutes.ts +++ b/server/src/api/routes/notificationRoutes.ts @@ -1,14 +1,15 @@ import { INotificationController } from "@/api/controllers/notificationController.js"; +import { isAllowed } from "@/api/middleware/isAllowed.js"; import { Router } from "express"; export const createNotificationRoutes = (notificationController: INotificationController): Router => { const router = Router(); - router.post("/", notificationController.createNotification); - router.post("/test/all", notificationController.testAllNotifications); - router.post("/test", notificationController.testNotification); + router.post("/", isAllowed(["admin", "superadmin"]), notificationController.createNotification); + router.post("/test/all", isAllowed(["admin", "superadmin"]), notificationController.testAllNotifications); + router.post("/test", isAllowed(["admin", "superadmin"]), notificationController.testNotification); router.get("/team", notificationController.getNotificationsByTeamId); router.get("/:id", notificationController.getNotificationById); - router.delete("/:id", notificationController.deleteNotification); - router.patch("/:id", notificationController.editNotification); + router.delete("/:id", isAllowed(["admin", "superadmin"]), notificationController.deleteNotification); + router.patch("/:id", isAllowed(["admin", "superadmin"]), notificationController.editNotification); return router; }; diff --git a/server/test/unit/middleware/isAllowed.test.ts b/server/test/unit/middleware/isAllowed.test.ts new file mode 100644 index 000000000..6354889b1 --- /dev/null +++ b/server/test/unit/middleware/isAllowed.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import type { NextFunction, Request, Response } from "express"; +import { isAllowed } from "../../../src/api/middleware/isAllowed.ts"; +import { AppError } from "../../../src/utils/AppError.ts"; + +describe("isAllowed middleware", () => { + it("calls next() without error when user has an allowed role", () => { + const middleware = isAllowed(["admin", "superadmin"]); + const req = { + user: { + userId: "user-1", + teamId: "team-1", + email: "admin@example.com", + role: ["admin"], + }, + } as unknown as Request; + const res = {} as Response; + const next = jest.fn() as unknown as NextFunction; + + middleware(req, res, next); + + expect(next).toHaveBeenCalledWith(); + }); + + it("calls next(error) with 403 when user does not have an allowed role", () => { + const middleware = isAllowed(["admin", "superadmin"]); + const req = { + user: { + userId: "user-2", + teamId: "team-1", + email: "user@example.com", + role: ["user"], + }, + } as unknown as Request; + const res = {} as Response; + const next = jest.fn() as unknown as NextFunction; + + middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + const error = (next as unknown as jest.Mock).mock.calls[0][0] as AppError; + expect(error).toBeInstanceOf(AppError); + expect(error.status).toBe(403); + expect(error.message).toBe("Unauthorized"); + }); + + it("calls next(error) with 403 when req.user is undefined", () => { + const middleware = isAllowed(["admin", "superadmin"]); + const req = {} as Request; + const res = {} as Response; + const next = jest.fn() as unknown as NextFunction; + + middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + const error = (next as unknown as jest.Mock).mock.calls[0][0] as AppError; + expect(error).toBeInstanceOf(AppError); + expect(error.status).toBe(403); + expect(error.message).toBe("Unauthorized"); + }); + + it("calls next() when user has multiple roles and at least one matches", () => { + const middleware = isAllowed(["admin", "superadmin"]); + const req = { + user: { + userId: "user-3", + teamId: "team-1", + email: "multi@example.com", + role: ["user", "admin"], + }, + } as unknown as Request; + const res = {} as Response; + const next = jest.fn() as unknown as NextFunction; + + middleware(req, res, next); + + expect(next).toHaveBeenCalledWith(); + }); +}); From 4bbc84ac48dd0fc1f9ccc75210ff10912e7e43f6 Mon Sep 17 00:00:00 2001 From: Malena Aguiar Date: Sun, 6 Sep 2026 08:41:41 -0600 Subject: [PATCH 50/89] feat(sidebar): reorder Docker and Pagespeed menu items Swap Docker and Pagespeed positions in the monitoring section of the sidebar so Docker appears before Infrastructure and Pagespeed comes last. Co-Authored-By: Claude Sonnet 4.6 --- client/src/Components/sidebar/Menu.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/src/Components/sidebar/Menu.tsx b/client/src/Components/sidebar/Menu.tsx index f8446f561..be42fa6e5 100644 --- a/client/src/Components/sidebar/Menu.tsx +++ b/client/src/Components/sidebar/Menu.tsx @@ -32,9 +32,9 @@ export const getMenu = (t: Function) => { icon: , }, { - name: t("components.sidebar.menu.pagespeed"), - path: "pagespeed", - icon: , + name: t("components.sidebar.menu.docker"), + path: "docker", + icon: , }, { name: t("components.sidebar.menu.infrastructure"), @@ -42,9 +42,9 @@ export const getMenu = (t: Function) => { icon: , }, { - name: t("components.sidebar.menu.docker"), - path: "docker", - icon: , + name: t("components.sidebar.menu.pagespeed"), + path: "pagespeed", + icon: , }, ], }, From 67806c94b15290d4add6922f65db6a80624b8bb9 Mon Sep 17 00:00:00 2001 From: egeoztass Date: Mon, 7 Sep 2026 12:31:07 +0200 Subject: [PATCH 51/89] fix: return the created monitor from createMonitor The controller writes the service's return value into the response, but createMonitor was typed Promise and returned nothing, so every successful POST /monitors answered data: null and callers had no way to read the new monitor's id. Fixes #3917 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01D7eViZ2Np5ZpWUYNoRhmAt --- server/src/domain/monitors/monitor.service.ts | 5 +++-- server/test/unit/services/monitorService.test.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index 43cba16d8..9e4f7f6e5 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -60,7 +60,7 @@ const computeRestartsInRange = (aggregate: DockerContainerStatsBucket[]): number export interface IMonitorService { // create - createMonitor(teamId: string, userId: string, body: Partial): Promise; + createMonitor(teamId: string, userId: string, body: Partial): Promise; createMonitors(monitors: Array): Promise; addDemoMonitors(args: { userId: string; teamId: string }): Promise; @@ -180,7 +180,7 @@ export class MonitorService implements IMonitorService { this.incidentsRepository = incidentsRepository; } - createMonitor = async (teamId: string, userId: string, body: Monitor): Promise => { + createMonitor = async (teamId: string, userId: string, body: Monitor): Promise => { // proxyId is only needed in custom mode if (body.proxyMode !== "custom") { delete body.proxyId; @@ -191,6 +191,7 @@ export class MonitorService implements IMonitorService { } this.scheduler.addJob(monitor.id, monitor); + return monitor; }; createMonitors = async (monitors: Array): Promise => { diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index 1bb4b3423..d3f5dd1a7 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -122,16 +122,17 @@ const createService = ( describe("MonitorService", () => { describe("createMonitor", () => { - it("creates a monitor and adds a job", async () => { + it("creates a monitor, adds a job and returns the created document", async () => { const monitorsRepository = createMonitorsRepositoryMock(); const monitor = makeMonitor(); (monitorsRepository.create as jest.Mock).mockResolvedValue(monitor); const { service, jobQueue } = createService({ monitorsRepository }); - await service.createMonitor(TEAM_ID, USER_ID, monitor as any); + const result = await service.createMonitor(TEAM_ID, USER_ID, monitor as any); expect(monitorsRepository.create).toHaveBeenCalledWith(monitor, TEAM_ID, USER_ID); expect(jobQueue.addJob).toHaveBeenCalledWith(MONITOR_ID, monitor); + expect(result).toBe(monitor); }); it("strips a stray proxyId when proxyMode is not custom", async () => { From 7fb8bab4beeb42dcdf12decc8c3133eb64b550f7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 8 Sep 2026 10:21:44 -0700 Subject: [PATCH 52/89] purge narrative comment --- client/src/Components/design-elements/Table.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx index 8650f2bc6..bba04f350 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -80,10 +80,6 @@ export function DataTable< const isSmall = useMediaQuery(theme.breakpoints.down("md")); - // Selecting text inside a row ends in a mouseup on that row, which would - // otherwise navigate away and take the text with it. Ignore the click when - // the selection is inside the row that was clicked - a leftover selection - // elsewhere on the page must not swallow a legitimate click. const hasTextSelectionWithin = (element: Element) => { const selection = window.getSelection(); if (!selection || selection.isCollapsed || !selection.toString().trim()) { From 8609e4bca0b2b2527be2c1365d589147d3ce33e0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 8 Sep 2026 10:46:36 -0700 Subject: [PATCH 53/89] add admin guard to test all button, fix tests --- .../monitors/HeaderMonitorControls.tsx | 30 ++++++++++--------- server/test/unit/middleware/isAllowed.test.ts | 6 ++-- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/client/src/Components/monitors/HeaderMonitorControls.tsx b/client/src/Components/monitors/HeaderMonitorControls.tsx index 7f6968f0b..c907b96bc 100644 --- a/client/src/Components/monitors/HeaderMonitorControls.tsx +++ b/client/src/Components/monitors/HeaderMonitorControls.tsx @@ -60,20 +60,22 @@ export const HeaderMonitorControls = ({ direction={{ xs: "column", md: "row" }} gap={theme.spacing(2)} > - + {isAdmin && ( + + )}