diff --git a/api/src/logs/__tests__/logs.controller.spec.ts b/api/src/logs/__tests__/logs.controller.spec.ts index 35da5225..678157c6 100644 --- a/api/src/logs/__tests__/logs.controller.spec.ts +++ b/api/src/logs/__tests__/logs.controller.spec.ts @@ -22,7 +22,7 @@ describe("LogsController", () => { getLogs: jest .fn() .mockResolvedValue( - Ok([{ date: "20260210", content: "test log line" }]), + Ok({ entries: [{ date: "20260210", content: "test log line" }], hasMore: false }), ), }; @@ -51,7 +51,9 @@ describe("LogsController", () => { describe("getLogs", () => { it("should return logs successfully", async () => { const mockLogs = [{ date: "20260210", content: "test log" }]; - (mockLogsService.getLogs as jest.Mock).mockResolvedValue(Ok(mockLogs)); + (mockLogsService.getLogs as jest.Mock).mockResolvedValue( + Ok({ entries: mockLogs, hasMore: false }), + ); const result = await controller.getLogs( "bot-123", @@ -61,6 +63,7 @@ describe("LogsController", () => { expect(result.success).toBe(true); expect(result.data).toEqual(mockLogs); + expect(result.hasMore).toBe(false); expect(mockLogsService.getLogs).toHaveBeenCalledWith( "bot-123", { date: undefined, dateRange: undefined, limit: 100, offset: 0 }, @@ -92,7 +95,9 @@ describe("LogsController", () => { const mockLogs = [ { date: "20260210", content: '{"_metric":true,"value":42}' }, ]; - (mockLogsService.getLogs as jest.Mock).mockResolvedValue(Ok(mockLogs)); + (mockLogsService.getLogs as jest.Mock).mockResolvedValue( + Ok({ entries: mockLogs, hasMore: false }), + ); await controller.getLogs( "bot-123", @@ -113,9 +118,67 @@ describe("LogsController", () => { ); }); + it("should return hasMore=true when entries equal limit", async () => { + const mockLogs = Array.from({ length: 100 }, (_, i) => ({ + date: "20260210", + content: `log-${i}`, + })); + (mockLogsService.getLogs as jest.Mock).mockResolvedValue( + Ok({ entries: mockLogs, hasMore: true }), + ); + + const result = await controller.getLogs( + "bot-123", + { limit: 100, offset: 0 } as any, + mockUser, + ); + + expect(result.success).toBe(true); + expect(result.data).toEqual(mockLogs); + expect(result.hasMore).toBe(true); + }); + + it("should return hasMore=false when entries less than limit", async () => { + const mockLogs = [{ date: "20260210", content: "only one" }]; + (mockLogsService.getLogs as jest.Mock).mockResolvedValue( + Ok({ entries: mockLogs, hasMore: false }), + ); + + const result = await controller.getLogs( + "bot-123", + { limit: 100, offset: 0 } as any, + mockUser, + ); + + expect(result.success).toBe(true); + expect(result.data).toEqual(mockLogs); + expect(result.hasMore).toBe(false); + }); + + it("should return hasMore=false for metrics queries", async () => { + const mockLogs = Array.from({ length: 100 }, (_, i) => ({ + date: "20260210", + content: `{"_metric":true,"value":${i}}`, + })); + (mockLogsService.getLogs as jest.Mock).mockResolvedValue( + Ok({ entries: mockLogs, hasMore: false }), + ); + + const result = await controller.getLogs( + "bot-123", + { limit: 100, offset: 0, type: "metrics" } as any, + mockUser, + ); + + expect(result.success).toBe(true); + expect(result.hasMore).toBe(false); + }); + it("should pass type=all parameter to service", async () => { const mockLogs = [{ date: "20260210", content: "some log" }]; - (mockLogsService.getLogs as jest.Mock).mockResolvedValue(Ok(mockLogs)); + (mockLogsService.getLogs as jest.Mock).mockResolvedValue( + Ok({ entries: mockLogs, hasMore: false }), + ); await controller.getLogs( "bot-123", diff --git a/api/src/logs/__tests__/logs.service.spec.ts b/api/src/logs/__tests__/logs.service.spec.ts index 6e391113..7ca5fc5b 100644 --- a/api/src/logs/__tests__/logs.service.spec.ts +++ b/api/src/logs/__tests__/logs.service.spec.ts @@ -38,6 +38,7 @@ describe("LogsService", () => { mockMinioClient = { statObject: jest.fn().mockResolvedValue({}), getObject: jest.fn(), + getPartialObject: jest.fn(), }; mockBotService = { @@ -81,18 +82,18 @@ describe("LogsService", () => { }); expect(result.success).toBe(true); - expect(result.data).toHaveLength(3); - expect(result.data![0]).toEqual({ + expect(result.data!.entries).toHaveLength(3); + expect(result.data!.entries[0]).toEqual({ date: "20260401", content: '{"level":"info","msg":"started"}', timestamp: null, }); - expect(result.data![1]).toEqual({ + expect(result.data!.entries[1]).toEqual({ date: "20260401", content: '{"level":"info","msg":"running"}', timestamp: null, }); - expect(result.data![2]).toEqual({ + expect(result.data!.entries[2]).toEqual({ date: "20260401", content: '{"level":"info","msg":"done"}', timestamp: null, @@ -118,9 +119,9 @@ describe("LogsService", () => { }); expect(result.success).toBe(true); - expect(result.data).toHaveLength(2); - expect(result.data![0].content).toContain('"_metric"'); - expect(result.data![1].content).toContain('"_metric"'); + expect(result.data!.entries).toHaveLength(2); + expect(result.data!.entries[0].content).toContain('"_metric"'); + expect(result.data!.entries[1].content).toContain('"_metric"'); }); it("should return all lines when type=all", async () => { @@ -140,10 +141,10 @@ describe("LogsService", () => { }); expect(result.success).toBe(true); - expect(result.data).toHaveLength(3); + expect(result.data!.entries).toHaveLength(3); }); - it("should respect limit at the line level", async () => { + it("should respect limit at the line level (tail returns last N)", async () => { const lines = Array.from( { length: 50 }, (_, i) => `{"level":"info","msg":"line-${i}"}`, @@ -158,33 +159,38 @@ describe("LogsService", () => { }); expect(result.success).toBe(true); - expect(result.data).toHaveLength(5); - expect(result.data![0].content).toContain("line-0"); - expect(result.data![4].content).toContain("line-4"); + expect(result.data!.entries).toHaveLength(5); + // Single-date tail reads return the LAST N lines + expect(result.data!.entries[0].content).toContain("line-45"); + expect(result.data!.entries[4].content).toContain("line-49"); }); - it("should respect offset at the line level", async () => { + it("should respect offset at the line level (date range uses stream)", async () => { + // Offset is meaningful for stream-from-start (multi-date range) queries const lines = Array.from( { length: 10 }, (_, i) => `{"level":"info","msg":"line-${i}"}`, ).join("\n"); - mockMinioClient.getObject.mockResolvedValue(stringStream(lines)); + // Use a two-day range so dates.length > 1 triggers the stream path + mockMinioClient.getObject + .mockResolvedValueOnce(stringStream(lines)) + .mockResolvedValueOnce(stringStream("")); // second day empty const result = await service.getLogs("bot-1", { - date: "20260401", + dateRange: "20260401-20260402", limit: 3, offset: 5, }); expect(result.success).toBe(true); - expect(result.data).toHaveLength(3); - expect(result.data![0].content).toContain("line-5"); - expect(result.data![1].content).toContain("line-6"); - expect(result.data![2].content).toContain("line-7"); + expect(result.data!.entries).toHaveLength(3); + expect(result.data!.entries[0].content).toContain("line-5"); + expect(result.data!.entries[1].content).toContain("line-6"); + expect(result.data!.entries[2].content).toContain("line-7"); }); - it("should combine limit and type=metrics filtering", async () => { + it("should return all metrics ignoring limit when type=metrics", async () => { const lines = [ '{"level":"info","msg":"noise"}', '{"_metric":true,"name":"m1","value":1}', @@ -205,9 +211,8 @@ describe("LogsService", () => { }); expect(result.success).toBe(true); - expect(result.data).toHaveLength(2); - expect(result.data![0].content).toContain("m1"); - expect(result.data![1].content).toContain("m2"); + // All 4 metrics returned despite limit=2 + expect(result.data!.entries).toHaveLength(4); }); it("should combine offset and type=metrics filtering", async () => { @@ -228,13 +233,13 @@ describe("LogsService", () => { }); expect(result.success).toBe(true); - expect(result.data).toHaveLength(2); - expect(result.data![0].content).toContain("m2"); - expect(result.data![1].content).toContain("m3"); + expect(result.data!.entries).toHaveLength(2); + expect(result.data!.entries[0].content).toContain("m2"); + expect(result.data!.entries[1].content).toContain("m3"); }); - it("should stop reading early once limit is reached", async () => { - // Use a large file but only need 2 lines + it("should stop reading early once limit is reached (stream path)", async () => { + // Use date range to trigger stream-from-start path const lines = Array.from( { length: 1000 }, (_, i) => `{"level":"info","msg":"line-${i}"}`, @@ -245,13 +250,13 @@ describe("LogsService", () => { mockMinioClient.getObject.mockResolvedValue(stream); const result = await service.getLogs("bot-1", { - date: "20260401", + dateRange: "20260401-20260401", limit: 2, offset: 0, }); expect(result.success).toBe(true); - expect(result.data).toHaveLength(2); + expect(result.data!.entries).toHaveLength(2); expect(destroySpy).toHaveBeenCalled(); }); @@ -272,10 +277,10 @@ describe("LogsService", () => { }); expect(result.success).toBe(true); - expect(result.data).toHaveLength(3); - expect(result.data![0].content).toContain("first"); - expect(result.data![1].content).toContain("second"); - expect(result.data![2].content).toContain("third"); + expect(result.data!.entries).toHaveLength(3); + expect(result.data!.entries[0].content).toContain("first"); + expect(result.data!.entries[1].content).toContain("second"); + expect(result.data!.entries[2].content).toContain("third"); }); it("should handle empty lines gracefully", async () => { @@ -290,11 +295,12 @@ describe("LogsService", () => { }); expect(result.success).toBe(true); - expect(result.data).toHaveLength(2); + expect(result.data!.entries).toHaveLength(2); }); it("should return empty array when log file does not exist", async () => { - mockMinioClient.getObject.mockRejectedValue({ code: "NoSuchKey" }); + // Single-date tail path calls statObject first + mockMinioClient.statObject.mockRejectedValue({ code: "NoSuchKey" }); const result = await service.getLogs("bot-1", { date: "20260401", @@ -303,7 +309,21 @@ describe("LogsService", () => { }); expect(result.success).toBe(true); - expect(result.data).toEqual([]); + expect(result.data!.entries).toEqual([]); + }); + + it("should return empty array when log file does not exist (date range)", async () => { + // Multi-date range triggers stream path, which handles NoSuchKey on getObject + mockMinioClient.getObject.mockRejectedValue({ code: "NoSuchKey" }); + + const result = await service.getLogs("bot-1", { + dateRange: "20260401-20260402", + limit: 100, + offset: 0, + }); + + expect(result.success).toBe(true); + expect(result.data!.entries).toEqual([]); }); it("should span multiple date files with line-level pagination", async () => { @@ -322,10 +342,10 @@ describe("LogsService", () => { }); expect(result.success).toBe(true); - expect(result.data).toHaveLength(4); - expect(result.data![0].content).toContain("d1-l1"); - expect(result.data![2].content).toContain("d1-l3"); - expect(result.data![3].content).toContain("d2-l1"); + expect(result.data!.entries).toHaveLength(4); + expect(result.data!.entries[0].content).toContain("d1-l1"); + expect(result.data!.entries[2].content).toContain("d1-l3"); + expect(result.data!.entries[3].content).toContain("d2-l1"); }); it("should apply offset across multiple date files", async () => { @@ -343,9 +363,9 @@ describe("LogsService", () => { }); expect(result.success).toBe(true); - expect(result.data).toHaveLength(2); - expect(result.data![0].content).toContain("d1-l2"); - expect(result.data![1].content).toContain("d2-l1"); + expect(result.data!.entries).toHaveLength(2); + expect(result.data!.entries[0].content).toContain("d1-l2"); + expect(result.data!.entries[1].content).toContain("d2-l1"); }); }); @@ -370,8 +390,8 @@ describe("LogsService", () => { expect(result.success).toBe(true); // Only the actual metric line should be returned, not the ones that // merely contain the string "_metric" in their message text - expect(result.data).toHaveLength(1); - expect(result.data![0].content).toContain('"name":"pnl"'); + expect(result.data!.entries).toHaveLength(1); + expect(result.data!.entries[0].content).toContain('"name":"pnl"'); }); it("should skip non-JSON lines when type=metrics", async () => { @@ -392,8 +412,8 @@ describe("LogsService", () => { }); expect(result.success).toBe(true); - expect(result.data).toHaveLength(1); - expect(result.data![0].content).toContain('"name":"pnl"'); + expect(result.data!.entries).toHaveLength(1); + expect(result.data!.entries[0].content).toContain('"name":"pnl"'); }); it("should return old format lines when type=all", async () => { @@ -415,19 +435,19 @@ describe("LogsService", () => { expect(result.success).toBe(true); // All lines should be returned regardless of format - expect(result.data).toHaveLength(4); - expect(result.data![0].content).toBe( + expect(result.data!.entries).toHaveLength(4); + expect(result.data!.entries[0].content).toBe( "[2026-03-30 13:00:26] INFO:main:Starting bot", ); - expect(result.data![0].timestamp).toBeNull(); - expect(result.data![1].content).toBe( + expect(result.data!.entries[0].timestamp).toBeNull(); + expect(result.data!.entries[1].content).toBe( '{"level":"info","msg":"json line"}', ); - expect(result.data![1].timestamp).toBeNull(); - expect(result.data![2].content).toBe("plain text log line"); - expect(result.data![2].timestamp).toBeNull(); - expect(result.data![3].content).toContain('"_metric"'); - expect(result.data![3].timestamp).toBeNull(); + expect(result.data!.entries[1].timestamp).toBeNull(); + expect(result.data!.entries[2].content).toBe("plain text log line"); + expect(result.data!.entries[2].timestamp).toBeNull(); + expect(result.data!.entries[3].content).toContain('"_metric"'); + expect(result.data!.entries[3].timestamp).toBeNull(); }); it("should handle leftover line with JSON-based metrics filtering", async () => { @@ -447,8 +467,8 @@ describe("LogsService", () => { expect(result.success).toBe(true); // The first line mentions _metric in text but isn't a metric; // the leftover (second line) IS a real metric - expect(result.data).toHaveLength(1); - expect(result.data![0].content).toContain('"name":"sharpe"'); + expect(result.data!.entries).toHaveLength(1); + expect(result.data!.entries[0].content).toContain('"name":"sharpe"'); }); it("should skip non-JSON leftover line when type=metrics", async () => { @@ -466,8 +486,8 @@ describe("LogsService", () => { }); expect(result.success).toBe(true); - expect(result.data).toHaveLength(1); - expect(result.data![0].content).toContain('"name":"pnl"'); + expect(result.data!.entries).toHaveLength(1); + expect(result.data!.entries[0].content).toContain('"name":"pnl"'); }); }); @@ -486,8 +506,8 @@ describe("LogsService", () => { }); expect(result.success).toBe(true); - expect(result.data).toHaveLength(1); - expect(result.data![0]).toEqual({ + expect(result.data!.entries).toHaveLength(1); + expect(result.data!.entries[0]).toEqual({ date: "20260330", content: "INFO:main:Starting bot", timestamp: "2026-03-30T13:00:26Z", @@ -508,8 +528,8 @@ describe("LogsService", () => { }); expect(result.success).toBe(true); - expect(result.data).toHaveLength(1); - expect(result.data![0]).toEqual({ + expect(result.data!.entries).toHaveLength(1); + expect(result.data!.entries[0]).toEqual({ date: "20260401", content: metricLine, timestamp: "2026-04-01T10:00:00Z", @@ -530,8 +550,8 @@ describe("LogsService", () => { }); expect(result.success).toBe(true); - expect(result.data).toHaveLength(1); - expect(result.data![0]).toEqual({ + expect(result.data!.entries).toHaveLength(1); + expect(result.data!.entries[0]).toEqual({ date: "20260330", content: "[2026-03-30 13:00:26] INFO:main:Starting bot", timestamp: null, @@ -555,9 +575,9 @@ describe("LogsService", () => { expect(result.success).toBe(true); // Only the real metric line should be returned - expect(result.data).toHaveLength(1); - expect(result.data![0].content).toContain('"_metric"'); - expect(result.data![0].timestamp).toBe("2026-04-01T10:00:00Z"); + expect(result.data!.entries).toHaveLength(1); + expect(result.data!.entries[0].content).toContain('"_metric"'); + expect(result.data!.entries[0].timestamp).toBe("2026-04-01T10:00:00Z"); }); it("should handle mixed old and new format lines", async () => { @@ -578,24 +598,24 @@ describe("LogsService", () => { }); expect(result.success).toBe(true); - expect(result.data).toHaveLength(3); + expect(result.data!.entries).toHaveLength(3); // Old format: content as-is, timestamp null - expect(result.data![0]).toEqual({ + expect(result.data!.entries[0]).toEqual({ date: "20260330", content: "[2026-03-30 13:00:26] INFO:main:Starting bot", timestamp: null, }); // NDJSON wrapped text: extracted message, extracted timestamp - expect(result.data![1]).toEqual({ + expect(result.data!.entries[1]).toEqual({ date: "20260330", content: "Running strategy", timestamp: "2026-03-30T13:00:27Z", }); // Metric line: full JSON as content, extracted timestamp - expect(result.data![2]).toEqual({ + expect(result.data!.entries[2]).toEqual({ date: "20260330", content: metricLine, timestamp: "2026-04-01T10:05:00Z", @@ -603,6 +623,330 @@ describe("LogsService", () => { }); }); + describe("getLogs - tail-based reads", () => { + it("should return latest entries from end of file (tail)", async () => { + // 100-line file; request limit=10 on a single date + const lines = Array.from( + { length: 100 }, + (_, i) => + `{"timestamp":"2026-04-01T${String(i).padStart(2, "0")}:00:00Z","message":"line-${i}"}`, + ); + const fileContent = lines.join("\n") + "\n"; + const fileSize = Buffer.byteLength(fileContent, "utf-8"); + + mockMinioClient.statObject.mockResolvedValue({ size: fileSize }); + // Tail window covers the whole file in this case, so getObject is used + // But for a proper tail test, simulate a file larger than TAIL_BYTES + // by using getPartialObject with the tail portion + const tailContent = lines.slice(-15).join("\n") + "\n"; + const tailSize = Buffer.byteLength(tailContent, "utf-8"); + const bigFileSize = 512 * 1024 + 1000; // bigger than TAIL_BYTES + + mockMinioClient.statObject.mockResolvedValue({ size: bigFileSize }); + // getPartialObject returns the last TAIL_BYTES, which we simulate + // as containing the last 15 lines (with a partial first line) + const partialFirstLine = "artial-junk-from-previous-line"; + const tailWithPartial = partialFirstLine + "\n" + tailContent; + mockMinioClient.getPartialObject.mockResolvedValue( + stringStream(tailWithPartial), + ); + + const result = await service.getLogs("bot-1", { + date: "20260401", + limit: 10, + offset: 0, + }); + + expect(result.success).toBe(true); + expect(result.data!.entries).toHaveLength(10); + // The returned entries should be the LAST 10 lines, not the first 10 + expect(result.data!.entries[0].content).toBe("line-90"); + expect(result.data!.entries[9].content).toBe("line-99"); + }); + + it("should return chronological entries for date range", async () => { + // Two dates with a few lines each - should stream from start + const day1 = [ + '{"timestamp":"2026-04-01T01:00:00Z","message":"d1-line1"}', + '{"timestamp":"2026-04-01T02:00:00Z","message":"d1-line2"}', + ].join("\n"); + const day2 = [ + '{"timestamp":"2026-04-02T01:00:00Z","message":"d2-line1"}', + '{"timestamp":"2026-04-02T02:00:00Z","message":"d2-line2"}', + ].join("\n"); + + mockMinioClient.getObject + .mockResolvedValueOnce(stringStream(day1)) + .mockResolvedValueOnce(stringStream(day2)); + + const result = await service.getLogs("bot-1", { + dateRange: "20260401-20260402", + limit: 10, + offset: 0, + }); + + expect(result.success).toBe(true); + expect(result.data!.entries).toHaveLength(4); + // Chronological order: day1 first, then day2 + expect(result.data!.entries[0].content).toBe("d1-line1"); + expect(result.data!.entries[1].content).toBe("d1-line2"); + expect(result.data!.entries[2].content).toBe("d2-line1"); + expect(result.data!.entries[3].content).toBe("d2-line2"); + }); + + it("should stream from start for metrics filter on single date", async () => { + const lines = [ + '{"level":"info","message":"noise"}', + '{"_metric":true,"name":"pnl","value":42}', + '{"level":"info","message":"more noise"}', + '{"_metric":true,"name":"sharpe","value":1.5}', + ].join("\n"); + + mockMinioClient.getObject.mockResolvedValue(stringStream(lines)); + + const result = await service.getLogs("bot-1", { + date: "20260401", + limit: 10, + offset: 0, + type: "metrics", + }); + + expect(result.success).toBe(true); + expect(result.data!.entries).toHaveLength(2); + // Metrics from anywhere in the file, streamed from start + expect(result.data!.entries[0].content).toContain('"name":"pnl"'); + expect(result.data!.entries[1].content).toContain('"name":"sharpe"'); + // getObject should have been called (not getPartialObject) + expect(mockMinioClient.getObject).toHaveBeenCalled(); + }); + + it("should return all metrics without limit when type=metrics", async () => { + // 200 lines: 150 noise + 50 metrics scattered throughout + const lines = Array.from({ length: 200 }, (_, i) => + i % 4 === 0 + ? `{"_metric":true,"name":"metric-${i}","value":${i}}` + : `{"level":"info","message":"noise-${i}"}`, + ).join("\n") + "\n"; + + mockMinioClient.getObject.mockResolvedValue(stringStream(lines)); + + const result = await service.getLogs("bot-1", { + date: "20260401", + limit: 10, + offset: 0, + type: "metrics", + }); + + expect(result.success).toBe(true); + // Should return all 50 metrics, not just 10 + expect(result.data!.entries.length).toBe(50); + }); + + it("should still respect limit for non-metrics queries", async () => { + const lines = Array.from( + { length: 50 }, + (_, i) => `{"timestamp":"2026-04-01T10:00:00Z","message":"line-${i}"}`, + ).join("\n") + "\n"; + + // Tail path: single date, non-metrics + mockMinioClient.statObject.mockResolvedValue({ size: 100 }); + mockMinioClient.getObject.mockResolvedValue(stringStream(lines)); + + const result = await service.getLogs("bot-1", { + date: "20260401", + limit: 10, + offset: 0, + }); + + expect(result.success).toBe(true); + expect(result.data!.entries.length).toBe(10); + }); + + it("should handle file smaller than tail window", async () => { + // Small file (well under 512KB) + const lines = [ + '{"timestamp":"2026-04-01T01:00:00Z","message":"line-1"}', + '{"timestamp":"2026-04-01T02:00:00Z","message":"line-2"}', + '{"timestamp":"2026-04-01T03:00:00Z","message":"line-3"}', + ]; + const fileContent = lines.join("\n") + "\n"; + const fileSize = Buffer.byteLength(fileContent, "utf-8"); + + mockMinioClient.statObject.mockResolvedValue({ size: fileSize }); + // File is smaller than TAIL_BYTES, so getObject is used + mockMinioClient.getObject.mockResolvedValue(stringStream(fileContent)); + + const result = await service.getLogs("bot-1", { + date: "20260401", + limit: 100, + offset: 0, + }); + + expect(result.success).toBe(true); + // All lines should be returned, nothing skipped + expect(result.data!.entries).toHaveLength(3); + expect(result.data!.entries[0].content).toBe("line-1"); + expect(result.data!.entries[1].content).toBe("line-2"); + expect(result.data!.entries[2].content).toBe("line-3"); + }); + + it("should drop partial first line when tailing from mid-file", async () => { + const bigFileSize = 512 * 1024 + 5000; + mockMinioClient.statObject.mockResolvedValue({ size: bigFileSize }); + + // Simulate getPartialObject returning content that starts mid-line + const tailContent = + 'oken-json-from-previous-line"}\n' + + '{"timestamp":"2026-04-01T10:00:00Z","message":"complete-line-1"}\n' + + '{"timestamp":"2026-04-01T11:00:00Z","message":"complete-line-2"}\n'; + + mockMinioClient.getPartialObject.mockResolvedValue( + stringStream(tailContent), + ); + + const result = await service.getLogs("bot-1", { + date: "20260401", + limit: 100, + offset: 0, + }); + + expect(result.success).toBe(true); + // The partial first line should be dropped + expect(result.data!.entries).toHaveLength(2); + expect(result.data!.entries[0].content).toBe("complete-line-1"); + expect(result.data!.entries[1].content).toBe("complete-line-2"); + // getPartialObject should have been called (not getObject) + expect(mockMinioClient.getPartialObject).toHaveBeenCalled(); + }); + }); + + describe("getLogs - datetime range filtering", () => { + it("should filter lines by timestamp when datetime range is provided", async () => { + const lines = [ + '{"timestamp":"2026-04-03T09:30:00Z","message":"before range"}', + '{"timestamp":"2026-04-03T10:15:00Z","message":"in range 1"}', + '{"timestamp":"2026-04-03T10:45:00Z","message":"in range 2"}', + '{"timestamp":"2026-04-03T11:30:00Z","message":"after range"}', + ].join("\n"); + + mockMinioClient.getObject.mockResolvedValue(stringStream(lines)); + + const result = await service.getLogs("bot-1", { + dateRange: "2026-04-03T10:00:00Z--2026-04-03T11:00:00Z", + limit: 100, + offset: 0, + }); + + expect(result.success).toBe(true); + expect(result.data!.entries).toHaveLength(2); + expect(result.data!.entries[0].content).toBe("in range 1"); + expect(result.data!.entries[1].content).toBe("in range 2"); + }); + + it("should still work with YYYYMMDD date range", async () => { + const day1 = '{"timestamp":"2026-04-01T12:00:00Z","message":"day1 line"}'; + const day2 = '{"timestamp":"2026-04-02T12:00:00Z","message":"day2 line"}'; + const day3 = '{"timestamp":"2026-04-03T12:00:00Z","message":"day3 line"}'; + + mockMinioClient.getObject + .mockResolvedValueOnce(stringStream(day1)) + .mockResolvedValueOnce(stringStream(day2)) + .mockResolvedValueOnce(stringStream(day3)); + + const result = await service.getLogs("bot-1", { + dateRange: "20260401-20260403", + limit: 100, + offset: 0, + }); + + expect(result.success).toBe(true); + expect(result.data!.entries).toHaveLength(3); + expect(result.data!.entries[0].content).toBe("day1 line"); + expect(result.data!.entries[1].content).toBe("day2 line"); + expect(result.data!.entries[2].content).toBe("day3 line"); + }); + + it("should handle datetime range spanning midnight", async () => { + const day1Lines = [ + '{"timestamp":"2026-04-02T22:00:00Z","message":"before range"}', + '{"timestamp":"2026-04-02T23:30:00Z","message":"late night"}', + ].join("\n"); + const day2Lines = [ + '{"timestamp":"2026-04-03T00:30:00Z","message":"early morning"}', + '{"timestamp":"2026-04-03T02:00:00Z","message":"after range"}', + ].join("\n"); + + mockMinioClient.getObject + .mockResolvedValueOnce(stringStream(day1Lines)) + .mockResolvedValueOnce(stringStream(day2Lines)); + + const result = await service.getLogs("bot-1", { + dateRange: "2026-04-02T23:00:00Z--2026-04-03T01:00:00Z", + limit: 100, + offset: 0, + }); + + expect(result.success).toBe(true); + expect(result.data!.entries).toHaveLength(2); + expect(result.data!.entries[0].content).toBe("late night"); + expect(result.data!.entries[1].content).toBe("early morning"); + }); + + it("should include lines without timestamps when filtering by datetime", async () => { + const lines = [ + '{"timestamp":"2026-04-03T10:15:00Z","message":"in range"}', + "[2026-04-03 09:00:00] INFO:main:old format no json timestamp", + "plain text line with no timestamp at all", + '{"timestamp":"2026-04-03T12:00:00Z","message":"after range"}', + ].join("\n"); + + mockMinioClient.getObject.mockResolvedValue(stringStream(lines)); + + const result = await service.getLogs("bot-1", { + dateRange: "2026-04-03T10:00:00Z--2026-04-03T11:00:00Z", + limit: 100, + offset: 0, + }); + + expect(result.success).toBe(true); + // "in range" is in window, old format and plain text have no timestamp so included, + // "after range" is outside the window so excluded + expect(result.data!.entries).toHaveLength(3); + expect(result.data!.entries[0].content).toBe("in range"); + expect(result.data!.entries[1].content).toBe( + "[2026-04-03 09:00:00] INFO:main:old format no json timestamp", + ); + expect(result.data!.entries[2].content).toBe( + "plain text line with no timestamp at all", + ); + }); + + it("should use tail path for single-date datetime range and filter by time", async () => { + // When the datetime range spans a single day, it should use the tail path + // but still apply time filtering + const lines = [ + '{"timestamp":"2026-04-03T08:00:00Z","message":"morning"}', + '{"timestamp":"2026-04-03T10:30:00Z","message":"mid-morning"}', + '{"timestamp":"2026-04-03T14:00:00Z","message":"afternoon"}', + ]; + const fileContent = lines.join("\n") + "\n"; + const fileSize = Buffer.byteLength(fileContent, "utf-8"); + + mockMinioClient.statObject.mockResolvedValue({ size: fileSize }); + mockMinioClient.getObject.mockResolvedValue(stringStream(fileContent)); + + const result = await service.getLogs("bot-1", { + dateRange: "2026-04-03T10:00:00Z--2026-04-03T11:00:00Z", + limit: 100, + offset: 0, + }); + + expect(result.success).toBe(true); + expect(result.data!.entries).toHaveLength(1); + expect(result.data!.entries[0].content).toBe("mid-morning"); + }); + }); + describe("getLogs - auth and validation", () => { it("should return failure when user is not authenticated", async () => { const unauthService = new LogsService( diff --git a/api/src/logs/dto/get-logs-query.dto.ts b/api/src/logs/dto/get-logs-query.dto.ts index a168db32..cb274147 100644 --- a/api/src/logs/dto/get-logs-query.dto.ts +++ b/api/src/logs/dto/get-logs-query.dto.ts @@ -14,7 +14,7 @@ export class GetLogsQueryDto { @Transform(({ value }) => parseInt(value)) @IsInt() @Min(1) - @Max(1000) + @Max(2000) limit?: number; @IsOptional() diff --git a/api/src/logs/logs.controller.ts b/api/src/logs/logs.controller.ts index 70af77df..038e3c16 100644 --- a/api/src/logs/logs.controller.ts +++ b/api/src/logs/logs.controller.ts @@ -89,7 +89,8 @@ export class LogsController { return { success: true, - data: result.data, + data: result.data.entries, + hasMore: result.data.hasMore, message: "Logs retrieved successfully", }; } @@ -213,7 +214,7 @@ export class LogsController { if (historyResult.success && historyResult.data) { res.write( - `event: history\ndata: ${JSON.stringify(historyResult.data)}\n\n`, + `event: history\ndata: ${JSON.stringify(historyResult.data.entries)}\n\n`, ); } else if (!historyResult.success) { res.write( diff --git a/api/src/logs/logs.service.ts b/api/src/logs/logs.service.ts index 8634d59a..646d289f 100644 --- a/api/src/logs/logs.service.ts +++ b/api/src/logs/logs.service.ts @@ -14,6 +14,9 @@ export interface LogsQuery { limit: number; offset: number; type?: "all" | "metrics"; + // Parsed datetime bounds (set by parseDateQuery for datetime ranges) + startTime?: Date; + endTime?: Date; } export interface LogEntry { @@ -24,6 +27,7 @@ export interface LogEntry { @Injectable({ scope: Scope.REQUEST }) export class LogsService { + private static readonly TAIL_BYTES = 512 * 1024; // 512KB private logBucket: string; constructor( @@ -40,7 +44,7 @@ export class LogsService { botId: string, query: LogsQuery, userId?: string, - ): Promise> { + ): Promise> { // Verify bot ownership const uid = userId || this.request?.user?.uid; if (!uid) return Failure("Authentication required"); @@ -53,21 +57,29 @@ export class LogsService { const dates = this.parseDateQuery(query); if (!dates.length) { return Failure( - "Invalid date or dateRange format. Use YYYYMMDD or YYYYMMDD-YYYYMMDD", + "Invalid date or dateRange format. Use YYYYMMDD, YYYYMMDD-YYYYMMDD, or ISO datetime range with -- separator", ); } try { const entries: LogEntry[] = []; - const skipped = { count: 0 }; - for (const date of dates) { - const logPath = `logs/${botId}/${date}.log`; - await this.streamFilteredLogs(logPath, date, query, entries, skipped); - if (entries.length >= query.limit) break; + if (dates.length === 1 && query.type !== "metrics" && !query.startTime) { + // Single date, non-metrics: tail for latest entries + const logPath = `logs/${botId}/${dates[0]}.log`; + await this.tailFilteredLogs(logPath, dates[0], query, entries); + } else { + // Multi-date or metrics: stream from start, chronological + const skipped = { count: 0 }; + for (const date of dates) { + const logPath = `logs/${botId}/${date}.log`; + await this.streamFilteredLogs(logPath, date, query, entries, skipped); + if (query.type !== "metrics" && entries.length >= query.limit) break; + } } - return Ok(entries); + const hasMore = query.type !== "metrics" && entries.length >= query.limit; + return Ok({ entries, hasMore }); } catch (error: unknown) { this.logger.error({ err: error }, "Error fetching logs"); return Failure(`Failed to fetch logs: ${errorMessage(error)}`); @@ -114,9 +126,25 @@ export class LogsService { continue; } - entries.push(this.normalizeLine(line, logDate)); + const normalized = this.normalizeLine(line, logDate); + + // Filter by datetime window if set + if (query.startTime && query.endTime) { + const entryTime = normalized.timestamp + ? new Date(normalized.timestamp) + : null; + if ( + entryTime && + (entryTime < query.startTime || entryTime > query.endTime) + ) { + continue; // Outside time window + } + // Lines without timestamps are included (can't filter, don't exclude) + } - if (entries.length >= query.limit) { + entries.push(normalized); + + if (query.type !== "metrics" && entries.length >= query.limit) { (stream as NodeJS.ReadableStream & { destroy?: () => void }).destroy?.(); return; } @@ -138,10 +166,97 @@ export class LogsService { if (includeLeftover) { if (skipped.count < query.offset) { skipped.count++; - } else if (entries.length < query.limit) { - entries.push(this.normalizeLine(leftover, logDate)); + } else if (query.type === "metrics" || entries.length < query.limit) { + const normalized = this.normalizeLine(leftover, logDate); + + // Filter by datetime window if set + if (query.startTime && query.endTime) { + const entryTime = normalized.timestamp + ? new Date(normalized.timestamp) + : null; + if ( + entryTime && + (entryTime < query.startTime || entryTime > query.endTime) + ) { + // Outside time window - skip + } else { + entries.push(normalized); + } + } else { + entries.push(normalized); + } + } + } + } + } + + private async tailFilteredLogs( + logPath: string, + logDate: string, + query: LogsQuery, + entries: LogEntry[], + ): Promise { + let stat: { size: number }; + try { + stat = await this.minioClient.statObject(this.logBucket, logPath); + } catch (error: unknown) { + if (hasErrorCode(error) && error.code === "NoSuchKey") return; + throw error; + } + + const fileSize = stat.size; + const start = Math.max(0, fileSize - LogsService.TAIL_BYTES); + + let stream: NodeJS.ReadableStream; + try { + stream = + start > 0 + ? await this.minioClient.getPartialObject( + this.logBucket, + logPath, + start, + ) + : await this.minioClient.getObject(this.logBucket, logPath); + } catch (error: unknown) { + // File may have been deleted between stat and read + if (hasErrorCode(error) && error.code === "NoSuchKey") return; + throw error; + } + + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const content = Buffer.concat(chunks).toString("utf-8"); + + const lines = content.split("\n"); + // Drop first line if reading from mid-file (likely partial) + if (start > 0) lines.shift(); + + for (const line of lines) { + if (!line.trim()) continue; + const normalized = this.normalizeLine(line, logDate); + + // Filter by datetime window if set + if (query.startTime && query.endTime) { + const entryTime = normalized.timestamp + ? new Date(normalized.timestamp) + : null; + if ( + entryTime && + (entryTime < query.startTime || entryTime > query.endTime) + ) { + continue; // Outside time window } + // Lines without timestamps are included (can't filter, don't exclude) } + + entries.push(normalized); + } + + // Keep only the last `limit` entries (skip for metrics - return all) + if (query.type !== "metrics" && entries.length > query.limit) { + entries.splice(0, entries.length - query.limit); } } @@ -173,6 +288,25 @@ export class LogsService { } if (query.dateRange) { + // Datetime range: contains T and uses -- separator + if (query.dateRange.includes("T")) { + const parts = query.dateRange.split("--"); + if (parts.length !== 2) return []; + const startTime = new Date(parts[0]); + const endTime = new Date(parts[1]); + if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) return []; + + // Store parsed times for line-level filtering + query.startTime = startTime; + query.endTime = endTime; + + // Generate date list from the datetime range + const startDate = parts[0].slice(0, 10).replace(/-/g, ""); + const endDate = parts[1].slice(0, 10).replace(/-/g, ""); + return this.generateDateRange(startDate, endDate); + } + + // Legacy YYYYMMDD-YYYYMMDD format const [startDate, endDate] = query.dateRange.split("-"); if ( !startDate || diff --git a/api/src/mcp/__tests__/mcp.service.spec.ts b/api/src/mcp/__tests__/mcp.service.spec.ts index 7524d91e..3a84d4e2 100644 --- a/api/src/mcp/__tests__/mcp.service.spec.ts +++ b/api/src/mcp/__tests__/mcp.service.spec.ts @@ -273,7 +273,7 @@ describe("McpService", () => { describe("logs_get", () => { it("should return bot logs", async () => { - logsService.getLogs.mockResolvedValue(Ok([mockLogEntry])); + logsService.getLogs.mockResolvedValue(Ok({ entries: [mockLogEntry], hasMore: false })); const result = await service.handleToolCall( "logs_get", @@ -289,7 +289,7 @@ describe("McpService", () => { }); it("should cap limit at 500", async () => { - logsService.getLogs.mockResolvedValue(Ok([])); + logsService.getLogs.mockResolvedValue(Ok({ entries: [], hasMore: false })); await service.handleToolCall( "logs_get", @@ -312,7 +312,7 @@ describe("McpService", () => { describe("logs_summary", () => { it("should return log summary", async () => { - logsService.getLogs.mockResolvedValue(Ok([mockLogEntry])); + logsService.getLogs.mockResolvedValue(Ok({ entries: [mockLogEntry], hasMore: false })); const result = await service.handleToolCall( "logs_summary", @@ -333,7 +333,7 @@ describe("McpService", () => { { date: "20251202", content: "success message", timestamp: "2025-12-02T10:01:00Z" }, { date: "20251202", content: "task failed to complete", timestamp: "2025-12-02T10:02:00Z" }, ]; - logsService.getLogs.mockResolvedValue(Ok(logsWithErrors)); + logsService.getLogs.mockResolvedValue(Ok({ entries: logsWithErrors, hasMore: false })); const result = await service.handleToolCall( "logs_summary", diff --git a/api/src/mcp/mcp.service.ts b/api/src/mcp/mcp.service.ts index 68cdd973..b8129300 100644 --- a/api/src/mcp/mcp.service.ts +++ b/api/src/mcp/mcp.service.ts @@ -497,10 +497,11 @@ export class McpService { if (!result.success) { throw new Error(result.error || "Failed to get logs"); } + const entries = result.data?.entries || []; return { bot_id: input.bot_id, - count: result.data?.length || 0, - logs: result.data || [], + count: entries.length, + logs: entries, }; } @@ -528,7 +529,7 @@ export class McpService { throw new Error(result.error || "Failed to get logs for summary"); } - const logs = result.data || []; + const logs = result.data?.entries || []; const errorCount = logs.filter( (log) => log.content?.toLowerCase().includes("error") || diff --git a/frontend/src/app/api/logs/[botId]/route.ts b/frontend/src/app/api/logs/[botId]/route.ts index e72277cb..ad27f9eb 100644 --- a/frontend/src/app/api/logs/[botId]/route.ts +++ b/frontend/src/app/api/logs/[botId]/route.ts @@ -10,18 +10,8 @@ export async function GET( const { botId } = await params; const { searchParams } = new URL(req.url); - // Extract query parameters - const date = searchParams.get("date"); - const dateRange = searchParams.get("dateRange"); - const limit = searchParams.get("limit") || "100"; - const offset = searchParams.get("offset") || "0"; - - // Build query string for backend API - const queryParams = new URLSearchParams(); - if (date) queryParams.set("date", date); - if (dateRange) queryParams.set("dateRange", dateRange); - queryParams.set("limit", limit); - queryParams.set("offset", offset); + // Forward all query parameters to backend API + const queryParams = new URLSearchParams(searchParams); const token = req.headers.get("Authorization"); const response = await fetch( diff --git a/frontend/src/components/bot/__tests__/console-interface.test.tsx b/frontend/src/components/bot/__tests__/console-interface.test.tsx index 1626b61a..b96844c1 100644 --- a/frontend/src/components/bot/__tests__/console-interface.test.tsx +++ b/frontend/src/components/bot/__tests__/console-interface.test.tsx @@ -13,6 +13,7 @@ jest.mock("react-virtuoso", () => ({ followOutput, atBottomStateChange, atTopStateChange, + endReached, overscan, className, }: any, @@ -23,6 +24,7 @@ jest.mock("react-virtuoso", () => ({ {data?.map((item: any, index: number) => (
{itemContent(index, item)}
))} + {components?.Footer && } ), ), @@ -653,6 +655,42 @@ describe("ConsoleInterface", () => { }); }); + describe("load more (pagination footer)", () => { + it("should render footer when hasMore is true", () => { + const logs: LogEntry[] = [ + { date: "2024-01-01", content: "[2024-01-01 10:00:00] INFO: Test log" }, + ]; + + render( + , + ); + + expect(screen.getByText("Load more")).toBeInTheDocument(); + }); + + it("should not render footer when hasMore is false", () => { + const logs: LogEntry[] = [ + { date: "2024-01-01", content: "[2024-01-01 10:00:00] INFO: Test log" }, + ]; + + render( + , + ); + + expect(screen.queryByText("Load more")).not.toBeInTheDocument(); + }); + }); + describe("rendering performance", () => { function makeLogs(count: number, prefix = "Log"): LogEntry[] { return Array.from({ length: count }, (_, i) => ({ diff --git a/frontend/src/components/bot/__tests__/interval-picker.test.tsx b/frontend/src/components/bot/__tests__/interval-picker.test.tsx index 1a6bd537..505bf9e1 100644 --- a/frontend/src/components/bot/__tests__/interval-picker.test.tsx +++ b/frontend/src/components/bot/__tests__/interval-picker.test.tsx @@ -31,7 +31,7 @@ describe("IntervalPicker", () => { mockOnChange.mockClear(); }); - it("should render all day presets", () => { + it("should render all presets including hour presets", () => { render( { /> ); + expect(screen.getByRole("button", { name: "1h" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "6h" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "1d" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "3d" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "7d" })).toBeInTheDocument(); @@ -114,6 +116,29 @@ describe("IntervalPicker", () => { expect(result.end).toMatch(/^\d{8}$/); }); + it("should call onChange with ISO datetime range when hour preset clicked", () => { + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "1h" })); + + expect(mockOnChange).toHaveBeenCalledTimes(1); + const result = mockOnChange.mock.calls[0][0]; + expect(result.label).toBe("1h"); + // Hour presets now return ISO datetime strings + expect(result.start).toContain("T"); + expect(result.end).toContain("T"); + // start should be before end + expect(new Date(result.start).getTime()).toBeLessThan( + new Date(result.end).getTime(), + ); + }); + it("should highlight the active interval", () => { const interval7d = computeInterval("7d", 7); render( @@ -159,6 +184,7 @@ describe("IntervalPicker", () => { describe("LIVE_INTERVAL constant", () => { it("should have label 'live' and empty start/end", () => { expect(LIVE_INTERVAL).toEqual({ + type: "live", label: "live", start: "", end: "", @@ -176,7 +202,7 @@ describe("IntervalPicker", () => { describe("computeInterval", () => { it("should compute correct date range for given days", () => { - const result = computeInterval("3d", 3); + const result = computeInterval("3d", { days: 3 }); expect(result.label).toBe("3d"); expect(result.start).toMatch(/^\d{8}$/); expect(result.end).toMatch(/^\d{8}$/); @@ -188,5 +214,29 @@ describe("IntervalPicker", () => { String(today.getDate()).padStart(2, "0"); expect(result.end).toBe(expectedEnd); }); + + it("should return ISO datetime range for hour presets", () => { + const result1h = computeInterval("1h", { hours: 1 }); + expect(result1h.label).toBe("1h"); + expect(result1h.start).toContain("T"); + expect(result1h.end).toContain("T"); + + // The start should be approximately 1 hour before end + const startMs = new Date(result1h.start).getTime(); + const endMs = new Date(result1h.end).getTime(); + const diffHours = (endMs - startMs) / (60 * 60 * 1000); + expect(diffHours).toBeCloseTo(1, 0); + + const result6h = computeInterval("6h", { hours: 6 }); + expect(result6h.label).toBe("6h"); + expect(result6h.start).toContain("T"); + expect(result6h.end).toContain("T"); + + const diff6h = + (new Date(result6h.end).getTime() - + new Date(result6h.start).getTime()) / + (60 * 60 * 1000); + expect(diff6h).toBeCloseTo(6, 0); + }); }); }); diff --git a/frontend/src/components/bot/console-interface.tsx b/frontend/src/components/bot/console-interface.tsx index 4e5a89fb..d4c57112 100644 --- a/frontend/src/components/bot/console-interface.tsx +++ b/frontend/src/components/bot/console-interface.tsx @@ -80,6 +80,12 @@ interface ConsoleInterfaceProps { loadingEarlier?: boolean; /** Callback to load earlier logs that were trimmed from the buffer */ onLoadEarlier?: () => void; + /** Whether there are more paginated logs available from the API */ + hasMore?: boolean; + /** Callback to load more paginated logs */ + loadMore?: () => void; + /** Whether more logs are currently being loaded */ + loadingMore?: boolean; } const LOG_LEVEL_COLORS = { @@ -436,6 +442,9 @@ export const ConsoleInterface: React.FC = ({ hasEarlierLogs, loadingEarlier, onLoadEarlier, + hasMore, + loadMore, + loadingMore, }) => { const [searchQuery, setSearchQuery] = useState(""); const [autoScroll, setAutoScroll] = useState(true); @@ -735,6 +744,7 @@ export const ConsoleInterface: React.FC = ({ itemContent={(index, log) => ( )} + endReached={hasMore && loadMore ? loadMore : undefined} components={{ Header: hasEarlierLogs && onLoadEarlier && !searchQuery @@ -762,6 +772,29 @@ export const ConsoleInterface: React.FC = ({ ) : undefined, + Footer: + hasMore && loadMore + ? () => ( +
+ +
+ ) + : undefined, }} className="h-full" /> diff --git a/frontend/src/components/bot/interval-picker.tsx b/frontend/src/components/bot/interval-picker.tsx index 76f4a37f..c2ab3ef7 100644 --- a/frontend/src/components/bot/interval-picker.tsx +++ b/frontend/src/components/bot/interval-picker.tsx @@ -13,9 +13,10 @@ import { CalendarIcon, Clock, Radio } from "lucide-react"; import type { DateRange } from "react-day-picker"; export interface IntervalValue { + type: "live" | "range"; label: string; - start: string; // YYYYMMDD format (empty for "live") - end: string; // YYYYMMDD format (empty for "live") + start: string; // YYYYMMDD or ISO datetime (empty for "live") + end: string; // YYYYMMDD or ISO datetime (empty for "live") } interface IntervalPickerProps { @@ -32,31 +33,49 @@ function formatDate(date: Date): string { } const PRESETS = [ + { label: "1h", hours: 1 }, + { label: "6h", hours: 6 }, { label: "1d", days: 1 }, { label: "3d", days: 3 }, { label: "7d", days: 7 }, { label: "30d", days: 30 }, ] as const; -export function computeInterval(label: string, days: number): IntervalValue { +export function computeInterval( + label: string, + preset: { hours?: number; days?: number }, +): IntervalValue { const now = new Date(); - const start = new Date(now); - start.setDate(start.getDate() - days + 1); + if (preset.hours) { + const start = new Date(now.getTime() - preset.hours * 60 * 60 * 1000); + return { + type: "range", + label, + start: start.toISOString(), + end: now.toISOString(), + }; + } + const startDay = new Date(now); + startDay.setDate(startDay.getDate() - (preset.days || 1) + 1); return { + type: "range", label, - start: formatDate(start), + start: formatDate(startDay), end: formatDate(now), }; } export const LIVE_INTERVAL: IntervalValue = { + type: "live", label: "live", start: "", end: "", }; /** Default interval: last 1 day */ -export const DEFAULT_DAY_INTERVAL: IntervalValue = computeInterval("1d", 1); +export const DEFAULT_DAY_INTERVAL: IntervalValue = computeInterval("1d", { + days: 1, +}); /** @deprecated Use DEFAULT_DAY_INTERVAL instead */ export const DEFAULT_INTERVAL: IntervalValue = DEFAULT_DAY_INTERVAL; @@ -73,6 +92,7 @@ export function IntervalPicker({ setRange(selected); if (selected?.from && selected?.to) { onChange({ + type: "range", label: "custom", start: formatDate(selected.from), end: formatDate(selected.to), @@ -112,7 +132,7 @@ export function IntervalPicker({ value.label === preset.label && "bg-secondary text-secondary-foreground", )} - onClick={() => onChange(computeInterval(preset.label, preset.days))} + onClick={() => onChange(computeInterval(preset.label, preset))} > {preset.label} diff --git a/frontend/src/components/dashboard/bot-detail-panel.tsx b/frontend/src/components/dashboard/bot-detail-panel.tsx index 2771cdfc..36b6b3bf 100644 --- a/frontend/src/components/dashboard/bot-detail-panel.tsx +++ b/frontend/src/components/dashboard/bot-detail-panel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "@/contexts/auth-context"; import { useRouter } from "next/navigation"; import moment from "moment"; @@ -83,9 +83,25 @@ export function BotDetailPanel({ botId }: BotDetailPanelProps) { // Interval picker state (shared by dashboard + console) // Realtime bots default to live mode; scheduled bots default to 1d range. + // Note: bot is null at mount, so useStreaming is initially false. The + // useEffect below corrects the interval once the bot loads. const [interval, setInterval_] = useState( useStreaming ? LIVE_INTERVAL : DEFAULT_DAY_INTERVAL, ); + const streamingInitialized = useRef(false); + + // Reset interval default when bot changes or streaming status resolves + useEffect(() => { + streamingInitialized.current = false; + }, [botId]); + + useEffect(() => { + if (!streamingInitialized.current && bot) { + streamingInitialized.current = true; + setInterval_(useStreaming ? LIVE_INTERVAL : DEFAULT_DAY_INTERVAL); + } + }, [useStreaming, bot]); + const hookBotId = bot !== null ? botId : ""; const streamHook = useBotLogsStream({ @@ -103,7 +119,7 @@ export function BotDetailPanel({ botId }: BotDetailPanelProps) { const handleIntervalChange = (val: IntervalValue) => { setInterval_(val); - if (val.label === "live") { + if (val.type === "live") { // Clear date filter so SSE streams all logs activeHook.setDateFilter(null); } else { @@ -126,6 +142,10 @@ export function BotDetailPanel({ botId }: BotDetailPanelProps) { const loadingEarlier = useStreaming ? streamHook.loadingEarlier : undefined; const loadEarlierLogs = useStreaming ? streamHook.loadEarlierLogs : undefined; + // Pagination: only relevant for REST polling (not SSE streaming) + const hasMoreLogs = !useStreaming ? pollingHook.hasMore : undefined; + const loadMoreLogs = !useStreaming ? pollingHook.loadMore : undefined; + useEffect(() => { const fetchBot = async () => { if (!botId || !user) return; @@ -426,6 +446,9 @@ export function BotDetailPanel({ botId }: BotDetailPanelProps) { hasEarlierLogs={hasEarlierLogs} loadingEarlier={loadingEarlier} loadEarlierLogs={loadEarlierLogs} + hasMore={hasMoreLogs} + loadMore={loadMoreLogs} + loadingMore={!useStreaming ? logsLoading : undefined} isUpdatingEnabled={isUpdatingEnabled} isDeleting={isDeleting} onToggleEnabled={handleToggleEnabled} @@ -542,7 +565,7 @@ export function BotDetailPanel({ botId }: BotDetailPanelProps) { botId={botId} customBotId={customBotId} version={bot.config.version} - dateRange={{ start: interval.start, end: interval.end }} + dateRange={interval.type === "range" ? { start: interval.start, end: interval.end } : undefined} className="" /> ) : ( @@ -579,6 +602,9 @@ export function BotDetailPanel({ botId }: BotDetailPanelProps) { hasEarlierLogs={hasEarlierLogs} loadingEarlier={loadingEarlier} onLoadEarlier={loadEarlierLogs} + hasMore={hasMoreLogs} + loadMore={loadMoreLogs} + loadingMore={!useStreaming ? logsLoading : undefined} className="h-full" compact /> diff --git a/frontend/src/components/dashboard/mobile-bot-detail.tsx b/frontend/src/components/dashboard/mobile-bot-detail.tsx index fce5987c..a96c3136 100644 --- a/frontend/src/components/dashboard/mobile-bot-detail.tsx +++ b/frontend/src/components/dashboard/mobile-bot-detail.tsx @@ -55,6 +55,9 @@ interface MobileBotDetailProps { hasEarlierLogs?: boolean; loadingEarlier?: boolean; loadEarlierLogs?: () => void; + hasMore?: boolean; + loadMore?: () => void; + loadingMore?: boolean; isUpdatingEnabled: boolean; isDeleting: boolean; onToggleEnabled: (enabled: boolean) => void; @@ -82,6 +85,9 @@ export function MobileBotDetail({ hasEarlierLogs, loadingEarlier, loadEarlierLogs, + hasMore, + loadMore, + loadingMore, isUpdatingEnabled, isDeleting, onToggleEnabled, @@ -172,6 +178,9 @@ export function MobileBotDetail({ hasEarlierLogs={hasEarlierLogs} loadingEarlier={loadingEarlier} onLoadEarlier={loadEarlierLogs} + hasMore={hasMore} + loadMore={loadMore} + loadingMore={loadingMore} className="h-full" compact /> diff --git a/frontend/src/hooks/use-bot-events.ts b/frontend/src/hooks/use-bot-events.ts index 5950447a..38a694a4 100644 --- a/frontend/src/hooks/use-bot-events.ts +++ b/frontend/src/hooks/use-bot-events.ts @@ -85,7 +85,9 @@ export function useBotEvents({ // Build initial query from dateRange; always request metrics type const initialQuery = dateRange ? { - dateRange: `${dateRange.start}-${dateRange.end}`, + dateRange: dateRange.start.includes("T") + ? `${dateRange.start}--${dateRange.end}` + : `${dateRange.start}-${dateRange.end}`, limit: 100, offset: 0, type: "metrics" as const, diff --git a/frontend/src/hooks/use-bot-logs-stream.ts b/frontend/src/hooks/use-bot-logs-stream.ts index 45365bb4..ab00a897 100644 --- a/frontend/src/hooks/use-bot-logs-stream.ts +++ b/frontend/src/hooks/use-bot-logs-stream.ts @@ -355,10 +355,12 @@ export const useBotLogsStream = ({ const setDateRangeFilter = useCallback( (start: string, end: string) => { dateFilterActiveRef.current = true; + // Use -- separator for ISO datetime ranges, - for YYYYMMDD date ranges + const separator = start.includes("T") ? "--" : "-"; const updatedQuery = { ...query, date: undefined, - dateRange: `${start}-${end}`, + dateRange: `${start}${separator}${end}`, offset: 0, }; setQuery(updatedQuery); diff --git a/frontend/src/hooks/use-bot-logs.ts b/frontend/src/hooks/use-bot-logs.ts index 3de3e1ef..561825cd 100644 --- a/frontend/src/hooks/use-bot-logs.ts +++ b/frontend/src/hooks/use-bot-logs.ts @@ -33,7 +33,7 @@ export const useBotLogs = ({ botId, autoRefresh = false, refreshInterval = 30000, // 30 seconds - initialQuery = { limit: 100, offset: 0 }, + initialQuery = { limit: 2000, offset: 0 }, }: UseBotLogsProps) => { const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(false); @@ -67,7 +67,9 @@ export const useBotLogs = ({ } const searchParams = new URLSearchParams(); - if (queryParams.date) { + if (queryParams.dateRange) { + searchParams.set("dateRange", queryParams.dateRange); + } else if (queryParams.date) { searchParams.set("date", queryParams.date); } else { searchParams.set( @@ -75,8 +77,6 @@ export const useBotLogs = ({ new Date().toISOString().slice(0, 10).replace(/-/g, ""), ); } - if (queryParams.dateRange) - searchParams.set("dateRange", queryParams.dateRange); if (queryParams.limit) searchParams.set("limit", queryParams.limit.toString()); if (queryParams.offset) @@ -201,8 +201,10 @@ export const useBotLogs = ({ // Set date range filter const setDateRangeFilter = useCallback( (startDate: string, endDate: string) => { + // Use -- separator for ISO datetime ranges, - for YYYYMMDD date ranges + const separator = startDate.includes("T") ? "--" : "-"; updateQuery({ - dateRange: `${startDate}-${endDate}`, + dateRange: `${startDate}${separator}${endDate}`, date: undefined, // Clear single date when range is set }); },