From 6e0afe223e2480a8b0d3030e2466cb4afeb0a528 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 12:06:17 -0700 Subject: [PATCH] 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 = {