feat: add Logs tab with cursor-paginated search, level filters, and live tail
- Extend GET /api/v1/logs with cursor pagination, multi-level filtering, optional application scoping, and level count aggregation - Add exchangeId, instanceId, application, mdc fields to log responses - Refactor ClickHouseLogStore with keyset pagination (N+1 pattern) - Add LogSearchRequest/LogSearchResponse core domain records - Create LogSearchPageResponse wrapper DTO - Add Logs as 4th content tab (Exchanges | Dashboard | Runtime | Logs) - Implement LogSearch component with debounced search, level filter bar, expandable log entries, cursor pagination, and live tail mode - Add cross-navigation: exchange header → logs, log tab → logs tab - Update ClickHouseLogStoreIT with cursor, multi-level, cross-app tests Closes: #104 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package com.cameleer3.server.app.search;
|
||||
|
||||
import com.cameleer3.common.model.LogEntry;
|
||||
import com.cameleer3.server.core.search.LogSearchRequest;
|
||||
import com.cameleer3.server.core.search.LogSearchResponse;
|
||||
import com.cameleer3.server.core.storage.LogEntryResult;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -52,6 +54,10 @@ class ClickHouseLogStoreIT {
|
||||
return new LogEntry(ts, level, logger, message, thread, stackTrace, mdc);
|
||||
}
|
||||
|
||||
private LogSearchRequest req(String application) {
|
||||
return new LogSearchRequest(null, null, application, null, null, null, null, null, null, 100, "desc");
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -78,10 +84,12 @@ class ClickHouseLogStoreIT {
|
||||
entry(now, "INFO", "logger", "msg-b", "t1", null, null)
|
||||
));
|
||||
|
||||
List<LogEntryResult> results = store.search("app-a", null, null, null, null, null, null, 100);
|
||||
LogSearchResponse result = store.search(req("app-a"));
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).message()).isEqualTo("msg-a");
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.data().get(0).message()).isEqualTo("msg-a");
|
||||
assertThat(result.data().get(0).application()).isEqualTo("app-a");
|
||||
assertThat(result.data().get(0).instanceId()).isEqualTo("agent-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -92,11 +100,27 @@ class ClickHouseLogStoreIT {
|
||||
entry(now.plusSeconds(1), "ERROR", "logger", "error message", "t1", null, null)
|
||||
));
|
||||
|
||||
List<LogEntryResult> results = store.search("my-app", null, "ERROR", null, null, null, null, 100);
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, List.of("ERROR"), "my-app", null, null, null, null, null, null, 100, "desc"));
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).level()).isEqualTo("ERROR");
|
||||
assertThat(results.get(0).message()).isEqualTo("error message");
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.data().get(0).level()).isEqualTo("ERROR");
|
||||
assertThat(result.data().get(0).message()).isEqualTo("error message");
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_multiLevel_filtersCorrectly() {
|
||||
Instant now = Instant.parse("2026-03-31T12:00:00Z");
|
||||
store.indexBatch("agent-1", "my-app", List.of(
|
||||
entry(now, "INFO", "logger", "info msg", "t1", null, null),
|
||||
entry(now.plusSeconds(1), "WARN", "logger", "warn msg", "t1", null, null),
|
||||
entry(now.plusSeconds(2), "ERROR", "logger", "error msg", "t1", null, null)
|
||||
));
|
||||
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, List.of("WARN", "ERROR"), "my-app", null, null, null, null, null, null, 100, "desc"));
|
||||
|
||||
assertThat(result.data()).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -107,10 +131,11 @@ class ClickHouseLogStoreIT {
|
||||
entry(now.plusSeconds(1), "INFO", "logger", "Health check OK", "t1", null, null)
|
||||
));
|
||||
|
||||
List<LogEntryResult> results = store.search("my-app", null, null, "order #12345", null, null, null, 100);
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
"order #12345", null, "my-app", null, null, null, null, null, null, 100, "desc"));
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).message()).contains("order #12345");
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.data().get(0).message()).contains("order #12345");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -123,10 +148,12 @@ class ClickHouseLogStoreIT {
|
||||
entry(now.plusSeconds(1), "INFO", "logger", "msg without exchange", "t1", null, null)
|
||||
));
|
||||
|
||||
List<LogEntryResult> results = store.search("my-app", null, null, null, "exchange-abc", null, null, 100);
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, "exchange-abc", null, null, null, null, 100, "desc"));
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).message()).isEqualTo("msg with exchange");
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.data().get(0).message()).isEqualTo("msg with exchange");
|
||||
assertThat(result.data().get(0).exchangeId()).isEqualTo("exchange-abc");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -141,14 +168,139 @@ class ClickHouseLogStoreIT {
|
||||
entry(t3, "INFO", "logger", "afternoon", "t1", null, null)
|
||||
));
|
||||
|
||||
// Query only the noon window
|
||||
Instant from = Instant.parse("2026-03-31T11:00:00Z");
|
||||
Instant to = Instant.parse("2026-03-31T13:00:00Z");
|
||||
|
||||
List<LogEntryResult> results = store.search("my-app", null, null, null, null, from, to, 100);
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, null, from, to, null, 100, "desc"));
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).message()).isEqualTo("noon");
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.data().get(0).message()).isEqualTo("noon");
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_crossApp_returnsAllApps() {
|
||||
Instant now = Instant.parse("2026-03-31T12:00:00Z");
|
||||
store.indexBatch("agent-1", "app-a", List.of(
|
||||
entry(now, "INFO", "logger", "msg-a", "t1", null, null)
|
||||
));
|
||||
store.indexBatch("agent-2", "app-b", List.of(
|
||||
entry(now, "INFO", "logger", "msg-b", "t1", null, null)
|
||||
));
|
||||
|
||||
// No application filter — should return both
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, 100, "desc"));
|
||||
|
||||
assertThat(result.data()).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_byLogger_filtersCorrectly() {
|
||||
Instant now = Instant.parse("2026-03-31T12:00:00Z");
|
||||
store.indexBatch("agent-1", "my-app", List.of(
|
||||
entry(now, "INFO", "com.example.OrderProcessor", "order msg", "t1", null, null),
|
||||
entry(now.plusSeconds(1), "INFO", "com.example.PaymentService", "payment msg", "t1", null, null)
|
||||
));
|
||||
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, "OrderProcessor", null, null, null, 100, "desc"));
|
||||
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.data().get(0).loggerName()).contains("OrderProcessor");
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_cursorPagination_works() {
|
||||
Instant base = Instant.parse("2026-03-31T12:00:00Z");
|
||||
store.indexBatch("agent-1", "my-app", List.of(
|
||||
entry(base, "INFO", "logger", "msg-1", "t1", null, null),
|
||||
entry(base.plusSeconds(1), "INFO", "logger", "msg-2", "t1", null, null),
|
||||
entry(base.plusSeconds(2), "INFO", "logger", "msg-3", "t1", null, null),
|
||||
entry(base.plusSeconds(3), "INFO", "logger", "msg-4", "t1", null, null),
|
||||
entry(base.plusSeconds(4), "INFO", "logger", "msg-5", "t1", null, null)
|
||||
));
|
||||
|
||||
// Page 1: limit 2
|
||||
LogSearchResponse page1 = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, null, null, null, null, 2, "desc"));
|
||||
|
||||
assertThat(page1.data()).hasSize(2);
|
||||
assertThat(page1.hasMore()).isTrue();
|
||||
assertThat(page1.nextCursor()).isNotNull();
|
||||
assertThat(page1.data().get(0).message()).isEqualTo("msg-5");
|
||||
|
||||
// Page 2: use cursor
|
||||
LogSearchResponse page2 = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, null, null, null, page1.nextCursor(), 2, "desc"));
|
||||
|
||||
assertThat(page2.data()).hasSize(2);
|
||||
assertThat(page2.hasMore()).isTrue();
|
||||
assertThat(page2.data().get(0).message()).isEqualTo("msg-3");
|
||||
|
||||
// Page 3: last page
|
||||
LogSearchResponse page3 = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, null, null, null, page2.nextCursor(), 2, "desc"));
|
||||
|
||||
assertThat(page3.data()).hasSize(1);
|
||||
assertThat(page3.hasMore()).isFalse();
|
||||
assertThat(page3.nextCursor()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_levelCounts_correctAndUnaffectedByLevelFilter() {
|
||||
Instant now = Instant.parse("2026-03-31T12:00:00Z");
|
||||
store.indexBatch("agent-1", "my-app", List.of(
|
||||
entry(now, "INFO", "logger", "info1", "t1", null, null),
|
||||
entry(now.plusSeconds(1), "INFO", "logger", "info2", "t1", null, null),
|
||||
entry(now.plusSeconds(2), "WARN", "logger", "warn1", "t1", null, null),
|
||||
entry(now.plusSeconds(3), "ERROR", "logger", "err1", "t1", null, null)
|
||||
));
|
||||
|
||||
// Filter for ERROR only, but counts should include all levels
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, List.of("ERROR"), "my-app", null, null, null, null, null, null, 100, "desc"));
|
||||
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.levelCounts()).containsEntry("INFO", 2L);
|
||||
assertThat(result.levelCounts()).containsEntry("WARN", 1L);
|
||||
assertThat(result.levelCounts()).containsEntry("ERROR", 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_sortAsc_returnsOldestFirst() {
|
||||
Instant base = Instant.parse("2026-03-31T12:00:00Z");
|
||||
store.indexBatch("agent-1", "my-app", List.of(
|
||||
entry(base, "INFO", "logger", "msg-1", "t1", null, null),
|
||||
entry(base.plusSeconds(1), "INFO", "logger", "msg-2", "t1", null, null),
|
||||
entry(base.plusSeconds(2), "INFO", "logger", "msg-3", "t1", null, null)
|
||||
));
|
||||
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, null, null, null, null, 100, "asc"));
|
||||
|
||||
assertThat(result.data()).hasSize(3);
|
||||
assertThat(result.data().get(0).message()).isEqualTo("msg-1");
|
||||
assertThat(result.data().get(2).message()).isEqualTo("msg-3");
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_returnsNewFields() {
|
||||
Instant now = Instant.parse("2026-03-31T12:00:00Z");
|
||||
Map<String, String> mdc = Map.of("camel.exchangeId", "ex-123", "custom.key", "custom-value");
|
||||
|
||||
store.indexBatch("agent-1", "my-app", List.of(
|
||||
entry(now, "INFO", "logger", "msg", "t1", null, mdc)
|
||||
));
|
||||
|
||||
LogSearchResponse result = store.search(req("my-app"));
|
||||
|
||||
assertThat(result.data()).hasSize(1);
|
||||
LogEntryResult entry = result.data().get(0);
|
||||
assertThat(entry.exchangeId()).isEqualTo("ex-123");
|
||||
assertThat(entry.instanceId()).isEqualTo("agent-1");
|
||||
assertThat(entry.application()).isEqualTo("my-app");
|
||||
assertThat(entry.mdc()).containsEntry("custom.key", "custom-value");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -163,13 +315,11 @@ class ClickHouseLogStoreIT {
|
||||
entry(now, "INFO", "logger", "msg", "t1", null, mdc)
|
||||
));
|
||||
|
||||
// Verify MDC is stored by querying raw data
|
||||
String exchangeId = jdbc.queryForObject(
|
||||
"SELECT exchange_id FROM logs WHERE application = 'my-app' LIMIT 1",
|
||||
String.class);
|
||||
assertThat(exchangeId).isEqualTo("ex-123");
|
||||
|
||||
// Verify MDC map contains custom key
|
||||
String customVal = jdbc.queryForObject(
|
||||
"SELECT mdc['custom.key'] FROM logs WHERE application = 'my-app' LIMIT 1",
|
||||
String.class);
|
||||
|
||||
Reference in New Issue
Block a user