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,7 +1,9 @@
|
|||||||
package com.cameleer3.server.app.controller;
|
package com.cameleer3.server.app.controller;
|
||||||
|
|
||||||
import com.cameleer3.server.app.dto.LogEntryResponse;
|
import com.cameleer3.server.app.dto.LogEntryResponse;
|
||||||
import com.cameleer3.server.core.storage.LogEntryResult;
|
import com.cameleer3.server.app.dto.LogSearchPageResponse;
|
||||||
|
import com.cameleer3.server.core.search.LogSearchRequest;
|
||||||
|
import com.cameleer3.server.core.search.LogSearchResponse;
|
||||||
import com.cameleer3.server.core.storage.LogIndex;
|
import com.cameleer3.server.core.storage.LogIndex;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@@ -12,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -27,30 +30,52 @@ public class LogQueryController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "Search application log entries",
|
@Operation(summary = "Search application log entries",
|
||||||
description = "Returns log entries for a given application, optionally filtered by agent, level, time range, and text query")
|
description = "Returns log entries with cursor-based pagination and level count aggregation. " +
|
||||||
public ResponseEntity<List<LogEntryResponse>> searchLogs(
|
"Supports free-text search, multi-level filtering, and optional application scoping.")
|
||||||
@RequestParam String application,
|
public ResponseEntity<LogSearchPageResponse> searchLogs(
|
||||||
@RequestParam(name = "agentId", required = false) String instanceId,
|
@RequestParam(required = false) String q,
|
||||||
@RequestParam(required = false) String level,
|
|
||||||
@RequestParam(required = false) String query,
|
@RequestParam(required = false) String query,
|
||||||
|
@RequestParam(required = false) String level,
|
||||||
|
@RequestParam(required = false) String application,
|
||||||
|
@RequestParam(name = "agentId", required = false) String instanceId,
|
||||||
@RequestParam(required = false) String exchangeId,
|
@RequestParam(required = false) String exchangeId,
|
||||||
|
@RequestParam(required = false) String logger,
|
||||||
@RequestParam(required = false) String from,
|
@RequestParam(required = false) String from,
|
||||||
@RequestParam(required = false) String to,
|
@RequestParam(required = false) String to,
|
||||||
@RequestParam(defaultValue = "200") int limit) {
|
@RequestParam(required = false) String cursor,
|
||||||
|
@RequestParam(defaultValue = "100") int limit,
|
||||||
|
@RequestParam(defaultValue = "desc") String sort) {
|
||||||
|
|
||||||
limit = Math.min(limit, 1000);
|
// q takes precedence over deprecated query param
|
||||||
|
String searchText = q != null ? q : query;
|
||||||
|
|
||||||
|
// Parse CSV levels
|
||||||
|
List<String> levels = List.of();
|
||||||
|
if (level != null && !level.isEmpty()) {
|
||||||
|
levels = Arrays.stream(level.split(","))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(s -> !s.isEmpty())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
Instant fromInstant = from != null ? Instant.parse(from) : null;
|
Instant fromInstant = from != null ? Instant.parse(from) : null;
|
||||||
Instant toInstant = to != null ? Instant.parse(to) : null;
|
Instant toInstant = to != null ? Instant.parse(to) : null;
|
||||||
|
|
||||||
List<LogEntryResult> results = logIndex.search(
|
LogSearchRequest request = new LogSearchRequest(
|
||||||
application, instanceId, level, query, exchangeId, fromInstant, toInstant, limit);
|
searchText, levels, application, instanceId, exchangeId,
|
||||||
|
logger, fromInstant, toInstant, cursor, limit, sort);
|
||||||
|
|
||||||
List<LogEntryResponse> entries = results.stream()
|
LogSearchResponse result = logIndex.search(request);
|
||||||
.map(r -> new LogEntryResponse(r.timestamp(), r.level(), r.loggerName(),
|
|
||||||
r.message(), r.threadName(), r.stackTrace()))
|
List<LogEntryResponse> entries = result.data().stream()
|
||||||
|
.map(r -> new LogEntryResponse(
|
||||||
|
r.timestamp(), r.level(), r.loggerName(),
|
||||||
|
r.message(), r.threadName(), r.stackTrace(),
|
||||||
|
r.exchangeId(), r.instanceId(), r.application(),
|
||||||
|
r.mdc()))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return ResponseEntity.ok(entries);
|
return ResponseEntity.ok(new LogSearchPageResponse(
|
||||||
|
entries, result.nextCursor(), result.hasMore(), result.levelCounts()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ package com.cameleer3.server.app.dto;
|
|||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Schema(description = "Application log entry")
|
@Schema(description = "Application log entry")
|
||||||
public record LogEntryResponse(
|
public record LogEntryResponse(
|
||||||
@Schema(description = "Log timestamp (ISO-8601)") String timestamp,
|
@Schema(description = "Log timestamp (ISO-8601)") String timestamp,
|
||||||
@Schema(description = "Log level (INFO, WARN, ERROR, DEBUG)") String level,
|
@Schema(description = "Log level (INFO, WARN, ERROR, DEBUG, TRACE)") String level,
|
||||||
@Schema(description = "Logger name") String loggerName,
|
@Schema(description = "Logger name") String loggerName,
|
||||||
@Schema(description = "Log message") String message,
|
@Schema(description = "Log message") String message,
|
||||||
@Schema(description = "Thread name") String threadName,
|
@Schema(description = "Thread name") String threadName,
|
||||||
@Schema(description = "Stack trace (if present)") String stackTrace
|
@Schema(description = "Stack trace (if present)") String stackTrace,
|
||||||
|
@Schema(description = "Camel exchange ID (if present)") String exchangeId,
|
||||||
|
@Schema(description = "Agent instance ID") String instanceId,
|
||||||
|
@Schema(description = "Application ID") String application,
|
||||||
|
@Schema(description = "MDC context map") Map<String, String> mdc
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.cameleer3.server.app.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Schema(description = "Log search response with cursor pagination and level counts")
|
||||||
|
public record LogSearchPageResponse(
|
||||||
|
@Schema(description = "Log entries for the current page") List<LogEntryResponse> data,
|
||||||
|
@Schema(description = "Cursor for next page (null if no more results)") String nextCursor,
|
||||||
|
@Schema(description = "Whether more results exist beyond this page") boolean hasMore,
|
||||||
|
@Schema(description = "Count of logs per level (unaffected by level filter)") Map<String, Long> levelCounts
|
||||||
|
) {}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.cameleer3.server.app.search;
|
package com.cameleer3.server.app.search;
|
||||||
|
|
||||||
import com.cameleer3.common.model.LogEntry;
|
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.cameleer3.server.core.storage.LogEntryResult;
|
||||||
import com.cameleer3.server.core.storage.LogIndex;
|
import com.cameleer3.server.core.storage.LogIndex;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -14,6 +16,7 @@ import java.time.format.DateTimeFormatter;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -55,12 +58,9 @@ public class ClickHouseLogStore implements LogIndex {
|
|||||||
ps.setString(7, entry.getThreadName() != null ? entry.getThreadName() : "");
|
ps.setString(7, entry.getThreadName() != null ? entry.getThreadName() : "");
|
||||||
ps.setString(8, entry.getStackTrace() != null ? entry.getStackTrace() : "");
|
ps.setString(8, entry.getStackTrace() != null ? entry.getStackTrace() : "");
|
||||||
|
|
||||||
// Extract camel.exchangeId from MDC into top-level column
|
|
||||||
Map<String, String> mdc = entry.getMdc() != null ? entry.getMdc() : Collections.emptyMap();
|
Map<String, String> mdc = entry.getMdc() != null ? entry.getMdc() : Collections.emptyMap();
|
||||||
String exchangeId = mdc.getOrDefault("camel.exchangeId", "");
|
String exchangeId = mdc.getOrDefault("camel.exchangeId", "");
|
||||||
ps.setString(9, exchangeId);
|
ps.setString(9, exchangeId);
|
||||||
|
|
||||||
// ClickHouse JDBC handles java.util.Map natively for Map columns
|
|
||||||
ps.setObject(10, mdc);
|
ps.setObject(10, mdc);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,62 +68,140 @@ public class ClickHouseLogStore implements LogIndex {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<LogEntryResult> search(String applicationId, String instanceId, String level,
|
public LogSearchResponse search(LogSearchRequest request) {
|
||||||
String query, String exchangeId,
|
// Build shared WHERE conditions (used by both data and count queries)
|
||||||
Instant from, Instant to, int limit) {
|
List<String> baseConditions = new ArrayList<>();
|
||||||
StringBuilder sql = new StringBuilder(
|
List<Object> baseParams = new ArrayList<>();
|
||||||
"SELECT timestamp, level, logger_name, message, thread_name, stack_trace " +
|
baseConditions.add("tenant_id = 'default'");
|
||||||
"FROM logs WHERE tenant_id = 'default' AND application = ?");
|
|
||||||
List<Object> params = new ArrayList<>();
|
|
||||||
params.add(applicationId);
|
|
||||||
|
|
||||||
if (instanceId != null && !instanceId.isEmpty()) {
|
if (request.application() != null && !request.application().isEmpty()) {
|
||||||
sql.append(" AND instance_id = ?");
|
baseConditions.add("application = ?");
|
||||||
params.add(instanceId);
|
baseParams.add(request.application());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (level != null && !level.isEmpty()) {
|
if (request.instanceId() != null && !request.instanceId().isEmpty()) {
|
||||||
sql.append(" AND level = ?");
|
baseConditions.add("instance_id = ?");
|
||||||
params.add(level.toUpperCase());
|
baseParams.add(request.instanceId());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exchangeId != null && !exchangeId.isEmpty()) {
|
if (request.exchangeId() != null && !request.exchangeId().isEmpty()) {
|
||||||
sql.append(" AND (exchange_id = ? OR (mapContains(mdc, 'camel.exchangeId') AND mdc['camel.exchangeId'] = ?))");
|
baseConditions.add("(exchange_id = ? OR (mapContains(mdc, 'camel.exchangeId') AND mdc['camel.exchangeId'] = ?))");
|
||||||
params.add(exchangeId);
|
baseParams.add(request.exchangeId());
|
||||||
params.add(exchangeId);
|
baseParams.add(request.exchangeId());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query != null && !query.isEmpty()) {
|
if (request.q() != null && !request.q().isEmpty()) {
|
||||||
sql.append(" AND message LIKE ?");
|
String term = "%" + escapeLike(request.q()) + "%";
|
||||||
params.add("%" + query + "%");
|
baseConditions.add("(message LIKE ? OR stack_trace LIKE ?)");
|
||||||
|
baseParams.add(term);
|
||||||
|
baseParams.add(term);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (from != null) {
|
if (request.logger() != null && !request.logger().isEmpty()) {
|
||||||
sql.append(" AND timestamp >= ?");
|
baseConditions.add("logger_name LIKE ?");
|
||||||
params.add(Timestamp.from(from));
|
baseParams.add("%" + escapeLike(request.logger()) + "%");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to != null) {
|
if (request.from() != null) {
|
||||||
sql.append(" AND timestamp <= ?");
|
baseConditions.add("timestamp >= ?");
|
||||||
params.add(Timestamp.from(to));
|
baseParams.add(Timestamp.from(request.from()));
|
||||||
}
|
}
|
||||||
|
|
||||||
sql.append(" ORDER BY timestamp DESC LIMIT ?");
|
if (request.to() != null) {
|
||||||
params.add(limit);
|
baseConditions.add("timestamp <= ?");
|
||||||
|
baseParams.add(Timestamp.from(request.to()));
|
||||||
|
}
|
||||||
|
|
||||||
return jdbc.query(sql.toString(), params.toArray(), (rs, rowNum) -> {
|
// Level counts query: uses base conditions WITHOUT level filter and cursor
|
||||||
|
String baseWhere = String.join(" AND ", baseConditions);
|
||||||
|
Map<String, Long> levelCounts = queryLevelCounts(baseWhere, baseParams);
|
||||||
|
|
||||||
|
// Data query conditions: add level filter and cursor on top of base
|
||||||
|
List<String> dataConditions = new ArrayList<>(baseConditions);
|
||||||
|
List<Object> dataParams = new ArrayList<>(baseParams);
|
||||||
|
|
||||||
|
if (request.levels() != null && !request.levels().isEmpty()) {
|
||||||
|
String placeholders = String.join(", ", Collections.nCopies(request.levels().size(), "?"));
|
||||||
|
dataConditions.add("level IN (" + placeholders + ")");
|
||||||
|
for (String lvl : request.levels()) {
|
||||||
|
dataParams.add(lvl.toUpperCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.cursor() != null && !request.cursor().isEmpty()) {
|
||||||
|
Instant cursorTs = Instant.parse(request.cursor());
|
||||||
|
if ("asc".equalsIgnoreCase(request.sort())) {
|
||||||
|
dataConditions.add("timestamp > ?");
|
||||||
|
} else {
|
||||||
|
dataConditions.add("timestamp < ?");
|
||||||
|
}
|
||||||
|
dataParams.add(Timestamp.from(cursorTs));
|
||||||
|
}
|
||||||
|
|
||||||
|
String dataWhere = String.join(" AND ", dataConditions);
|
||||||
|
String orderDir = "asc".equalsIgnoreCase(request.sort()) ? "ASC" : "DESC";
|
||||||
|
int fetchLimit = request.limit() + 1; // fetch N+1 to detect hasMore
|
||||||
|
|
||||||
|
String dataSql = "SELECT timestamp, level, logger_name, message, thread_name, stack_trace, " +
|
||||||
|
"exchange_id, instance_id, application, mdc " +
|
||||||
|
"FROM logs WHERE " + dataWhere +
|
||||||
|
" ORDER BY timestamp " + orderDir + " LIMIT ?";
|
||||||
|
dataParams.add(fetchLimit);
|
||||||
|
|
||||||
|
List<LogEntryResult> results = jdbc.query(dataSql, dataParams.toArray(), (rs, rowNum) -> {
|
||||||
Timestamp ts = rs.getTimestamp("timestamp");
|
Timestamp ts = rs.getTimestamp("timestamp");
|
||||||
String timestampStr = ts != null
|
String timestampStr = ts != null
|
||||||
? ts.toInstant().atOffset(ZoneOffset.UTC).format(DateTimeFormatter.ISO_INSTANT)
|
? ts.toInstant().atOffset(ZoneOffset.UTC).format(ISO_FMT)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, String> mdc = (Map<String, String>) rs.getObject("mdc");
|
||||||
|
if (mdc == null) mdc = Collections.emptyMap();
|
||||||
|
|
||||||
return new LogEntryResult(
|
return new LogEntryResult(
|
||||||
timestampStr,
|
timestampStr,
|
||||||
rs.getString("level"),
|
rs.getString("level"),
|
||||||
rs.getString("logger_name"),
|
rs.getString("logger_name"),
|
||||||
rs.getString("message"),
|
rs.getString("message"),
|
||||||
rs.getString("thread_name"),
|
rs.getString("thread_name"),
|
||||||
rs.getString("stack_trace")
|
rs.getString("stack_trace"),
|
||||||
|
rs.getString("exchange_id"),
|
||||||
|
rs.getString("instance_id"),
|
||||||
|
rs.getString("application"),
|
||||||
|
mdc
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
boolean hasMore = results.size() > request.limit();
|
||||||
|
if (hasMore) {
|
||||||
|
results = new ArrayList<>(results.subList(0, request.limit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
String nextCursor = null;
|
||||||
|
if (hasMore && !results.isEmpty()) {
|
||||||
|
nextCursor = results.get(results.size() - 1).timestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LogSearchResponse(results, nextCursor, hasMore, levelCounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Long> queryLevelCounts(String baseWhere, List<Object> baseParams) {
|
||||||
|
String sql = "SELECT level, count() AS cnt FROM logs WHERE " + baseWhere + " GROUP BY level";
|
||||||
|
Map<String, Long> counts = new LinkedHashMap<>();
|
||||||
|
try {
|
||||||
|
jdbc.query(sql, baseParams.toArray(), (rs, rowNum) -> {
|
||||||
|
counts.put(rs.getString("level"), rs.getLong("cnt"));
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to query level counts", e);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String escapeLike(String term) {
|
||||||
|
return term.replace("\\", "\\\\")
|
||||||
|
.replace("%", "\\%")
|
||||||
|
.replace("_", "\\_");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.cameleer3.server.app.search;
|
package com.cameleer3.server.app.search;
|
||||||
|
|
||||||
import com.cameleer3.common.model.LogEntry;
|
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.cameleer3.server.core.storage.LogEntryResult;
|
||||||
import com.zaxxer.hikari.HikariDataSource;
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -52,6 +54,10 @@ class ClickHouseLogStoreIT {
|
|||||||
return new LogEntry(ts, level, logger, message, thread, stackTrace, mdc);
|
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 ─────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -78,10 +84,12 @@ class ClickHouseLogStoreIT {
|
|||||||
entry(now, "INFO", "logger", "msg-b", "t1", null, null)
|
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(result.data()).hasSize(1);
|
||||||
assertThat(results.get(0).message()).isEqualTo("msg-a");
|
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
|
@Test
|
||||||
@@ -92,11 +100,27 @@ class ClickHouseLogStoreIT {
|
|||||||
entry(now.plusSeconds(1), "ERROR", "logger", "error message", "t1", null, null)
|
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(result.data()).hasSize(1);
|
||||||
assertThat(results.get(0).level()).isEqualTo("ERROR");
|
assertThat(result.data().get(0).level()).isEqualTo("ERROR");
|
||||||
assertThat(results.get(0).message()).isEqualTo("error message");
|
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
|
@Test
|
||||||
@@ -107,10 +131,11 @@ class ClickHouseLogStoreIT {
|
|||||||
entry(now.plusSeconds(1), "INFO", "logger", "Health check OK", "t1", null, null)
|
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(result.data()).hasSize(1);
|
||||||
assertThat(results.get(0).message()).contains("order #12345");
|
assertThat(result.data().get(0).message()).contains("order #12345");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -123,10 +148,12 @@ class ClickHouseLogStoreIT {
|
|||||||
entry(now.plusSeconds(1), "INFO", "logger", "msg without exchange", "t1", null, null)
|
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(result.data()).hasSize(1);
|
||||||
assertThat(results.get(0).message()).isEqualTo("msg with exchange");
|
assertThat(result.data().get(0).message()).isEqualTo("msg with exchange");
|
||||||
|
assertThat(result.data().get(0).exchangeId()).isEqualTo("exchange-abc");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -141,14 +168,139 @@ class ClickHouseLogStoreIT {
|
|||||||
entry(t3, "INFO", "logger", "afternoon", "t1", null, null)
|
entry(t3, "INFO", "logger", "afternoon", "t1", null, null)
|
||||||
));
|
));
|
||||||
|
|
||||||
// Query only the noon window
|
|
||||||
Instant from = Instant.parse("2026-03-31T11:00:00Z");
|
Instant from = Instant.parse("2026-03-31T11:00:00Z");
|
||||||
Instant to = Instant.parse("2026-03-31T13: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(result.data()).hasSize(1);
|
||||||
assertThat(results.get(0).message()).isEqualTo("noon");
|
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
|
@Test
|
||||||
@@ -163,13 +315,11 @@ class ClickHouseLogStoreIT {
|
|||||||
entry(now, "INFO", "logger", "msg", "t1", null, mdc)
|
entry(now, "INFO", "logger", "msg", "t1", null, mdc)
|
||||||
));
|
));
|
||||||
|
|
||||||
// Verify MDC is stored by querying raw data
|
|
||||||
String exchangeId = jdbc.queryForObject(
|
String exchangeId = jdbc.queryForObject(
|
||||||
"SELECT exchange_id FROM logs WHERE application = 'my-app' LIMIT 1",
|
"SELECT exchange_id FROM logs WHERE application = 'my-app' LIMIT 1",
|
||||||
String.class);
|
String.class);
|
||||||
assertThat(exchangeId).isEqualTo("ex-123");
|
assertThat(exchangeId).isEqualTo("ex-123");
|
||||||
|
|
||||||
// Verify MDC map contains custom key
|
|
||||||
String customVal = jdbc.queryForObject(
|
String customVal = jdbc.queryForObject(
|
||||||
"SELECT mdc['custom.key'] FROM logs WHERE application = 'my-app' LIMIT 1",
|
"SELECT mdc['custom.key'] FROM logs WHERE application = 'my-app' LIMIT 1",
|
||||||
String.class);
|
String.class);
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.cameleer3.server.core.search;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable search criteria for querying application logs.
|
||||||
|
*
|
||||||
|
* @param q free-text search across message and stack trace
|
||||||
|
* @param levels log level filter (e.g. ["WARN","ERROR"])
|
||||||
|
* @param application application ID filter (nullable = all apps)
|
||||||
|
* @param instanceId agent instance ID filter
|
||||||
|
* @param exchangeId Camel exchange ID filter
|
||||||
|
* @param logger logger name substring filter
|
||||||
|
* @param from inclusive start of time range (required)
|
||||||
|
* @param to inclusive end of time range (required)
|
||||||
|
* @param cursor ISO timestamp cursor for keyset pagination
|
||||||
|
* @param limit page size (1-500, default 100)
|
||||||
|
* @param sort sort direction: "asc" or "desc" (default "desc")
|
||||||
|
*/
|
||||||
|
public record LogSearchRequest(
|
||||||
|
String q,
|
||||||
|
List<String> levels,
|
||||||
|
String application,
|
||||||
|
String instanceId,
|
||||||
|
String exchangeId,
|
||||||
|
String logger,
|
||||||
|
Instant from,
|
||||||
|
Instant to,
|
||||||
|
String cursor,
|
||||||
|
int limit,
|
||||||
|
String sort
|
||||||
|
) {
|
||||||
|
|
||||||
|
private static final int DEFAULT_LIMIT = 100;
|
||||||
|
private static final int MAX_LIMIT = 500;
|
||||||
|
|
||||||
|
public LogSearchRequest {
|
||||||
|
if (limit <= 0) limit = DEFAULT_LIMIT;
|
||||||
|
if (limit > MAX_LIMIT) limit = MAX_LIMIT;
|
||||||
|
if (sort == null || !"asc".equalsIgnoreCase(sort)) sort = "desc";
|
||||||
|
if (levels == null) levels = List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.cameleer3.server.core.search;
|
||||||
|
|
||||||
|
import com.cameleer3.server.core.storage.LogEntryResult;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log search result with cursor-based pagination and level aggregation.
|
||||||
|
*
|
||||||
|
* @param data matching log entries for the current page
|
||||||
|
* @param nextCursor ISO timestamp cursor for the next page (null if no more)
|
||||||
|
* @param hasMore whether more results exist beyond this page
|
||||||
|
* @param levelCounts count of matching logs per level (unaffected by level filter)
|
||||||
|
*/
|
||||||
|
public record LogSearchResponse(
|
||||||
|
List<LogEntryResult> data,
|
||||||
|
String nextCursor,
|
||||||
|
boolean hasMore,
|
||||||
|
Map<String, Long> levelCounts
|
||||||
|
) {}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.cameleer3.server.core.storage;
|
package com.cameleer3.server.core.storage;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.util.Map;
|
||||||
|
|
||||||
public record LogEntryResult(String timestamp, String level, String loggerName,
|
public record LogEntryResult(String timestamp, String level, String loggerName,
|
||||||
String message, String threadName, String stackTrace) {}
|
String message, String threadName, String stackTrace,
|
||||||
|
String exchangeId, String instanceId, String application,
|
||||||
|
Map<String, String> mdc) {}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
package com.cameleer3.server.core.storage;
|
package com.cameleer3.server.core.storage;
|
||||||
|
|
||||||
import com.cameleer3.common.model.LogEntry;
|
import com.cameleer3.common.model.LogEntry;
|
||||||
|
import com.cameleer3.server.core.search.LogSearchRequest;
|
||||||
|
import com.cameleer3.server.core.search.LogSearchResponse;
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface LogIndex {
|
public interface LogIndex {
|
||||||
|
|
||||||
List<LogEntryResult> search(String applicationId, String instanceId, String level,
|
LogSearchResponse search(LogSearchRequest request);
|
||||||
String query, String exchangeId,
|
|
||||||
Instant from, Instant to, int limit);
|
|
||||||
|
|
||||||
void indexBatch(String instanceId, String applicationId, List<LogEntry> entries);
|
void indexBatch(String instanceId, String applicationId, List<LogEntry> entries);
|
||||||
}
|
}
|
||||||
|
|||||||
95
docs/superpowers/specs/2026-04-01-ux-audit-pmf-readiness.md
Normal file
95
docs/superpowers/specs/2026-04-01-ux-audit-pmf-readiness.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# UX Audit: PMF Readiness for First Market Offer
|
||||||
|
|
||||||
|
**Date:** 2026-04-01
|
||||||
|
**Epic:** Gitea #100
|
||||||
|
**Timeline:** 8 weeks to first market offer (~2026-05-27)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Comprehensive UX audit evaluating readiness for product-market fit. Full-stack Apache Camel observability platform competing with Datadog, Grafana+Tempo, Dynatrace. Self-hosted first, SaaS later.
|
||||||
|
|
||||||
|
**Three target personas (all equally important):**
|
||||||
|
- Integration Developers -- debug message flows, trace exchanges, inspect payloads/errors
|
||||||
|
- DevOps/Platform Engineers -- manage deployments, agent health, route control, config push
|
||||||
|
- Engineering Managers / Tech Leads -- dashboards, SLA compliance, error trends
|
||||||
|
|
||||||
|
**Competitive positioning:** "General APM tools don't understand Camel. We do."
|
||||||
|
|
||||||
|
## What's Working Well
|
||||||
|
|
||||||
|
- Process diagram visualization -- killer differentiator, no APM tool shows Camel routes this way
|
||||||
|
- Three-tab navigation (Exchanges/Dashboard/Runtime) maps cleanly to personas
|
||||||
|
- Command palette (Ctrl+K) with categorized search is polished
|
||||||
|
- Dashboard 3-level drill-down with KPIs, treemaps, punchcard heatmaps -- competitive-grade
|
||||||
|
- Dark mode is clean and well-implemented
|
||||||
|
- Design system gives visual consistency; amber/brown brand is distinctive
|
||||||
|
- Route control bar (Start/Stop/Suspend/Resume/Replay) -- unique differentiator
|
||||||
|
- Live auto-refresh with LIVE indicator
|
||||||
|
- Correlation chain navigation
|
||||||
|
|
||||||
|
## Issues Created
|
||||||
|
|
||||||
|
### P0 -- Ship Blockers
|
||||||
|
- #101 Onboarding & empty state experience (spec posted)
|
||||||
|
- #102 Alerting & notification system (spec posted)
|
||||||
|
- #103 Shareable links with filter state (spec posted)
|
||||||
|
|
||||||
|
### P1 -- Must Have
|
||||||
|
- #104 Log search experience (spec posted)
|
||||||
|
- #105 Exchange table readability (spec posted)
|
||||||
|
- #106 Latency outlier investigation path (spec posted)
|
||||||
|
|
||||||
|
### P2 -- Should Have
|
||||||
|
- #107 Data export CSV/JSON (spec posted)
|
||||||
|
- #108 Sidebar consolidation & tab-awareness (spec posted)
|
||||||
|
- #109 Dashboard L3 diagram readability (spec posted)
|
||||||
|
- #110 Time/locale formatting consistency (spec posted)
|
||||||
|
- #111 Pagination & deep result access (spec posted)
|
||||||
|
|
||||||
|
### P3 -- Polish
|
||||||
|
- #112 Admin page context separation (spec posted)
|
||||||
|
- #113 Runtime suspended routes context (spec posted)
|
||||||
|
- #114 App Config detail full page (spec posted)
|
||||||
|
- #115 Comparative & historical analysis (spec posted)
|
||||||
|
|
||||||
|
## Recommended 8-Week Execution Order
|
||||||
|
|
||||||
|
| Weeks | Issues | Focus |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| 1-2 | #103, #105, #110 | Quick wins: shareable links, table readability, formatting |
|
||||||
|
| 3-4 | #101, #111 | First-run experience, pagination |
|
||||||
|
| 5-6 | #102 | Alerting (the big sticky feature) |
|
||||||
|
| 7-8 | #104, #106 | Depth: log search, latency investigation |
|
||||||
|
|
||||||
|
## Design Specs
|
||||||
|
|
||||||
|
Full design specifications are posted as comments on each Gitea issue. Key architectural decisions:
|
||||||
|
|
||||||
|
### #101 Onboarding
|
||||||
|
- `useOnboardingState()` hook derives phase from existing `useAgents()` + `useRouteCatalog()` polling
|
||||||
|
- Phases: welcome -> connected -> receiving -> complete -> dismissed
|
||||||
|
- Framework-specific snippets (Spring Boot/Quarkus/Standalone x Maven/Gradle)
|
||||||
|
- Bootstrap token via new `GET /api/v1/admin/bootstrap-token` (ADMIN only)
|
||||||
|
- Per-page empty states using DS `EmptyState` component
|
||||||
|
|
||||||
|
### #102 Alerting
|
||||||
|
- PostgreSQL tables: alert_channels, alert_rules, alert_history, alert_rule_state
|
||||||
|
- 7 built-in alert types querying ClickHouse MVs and agent registry
|
||||||
|
- Evaluation engine: Spring @Scheduled, 10s loop, hysteresis, cooldown
|
||||||
|
- 3 notification channels: webhook, email (SMTP), Slack
|
||||||
|
- Bell icon in TopBar with firing count badge
|
||||||
|
- New "Alerting" admin tab with Rules/Channels/History sub-tabs
|
||||||
|
|
||||||
|
### #103 Shareable Links
|
||||||
|
- URL as canonical source of truth, React state is derived mirror
|
||||||
|
- `UrlFilterSyncProvider` wraps existing `GlobalFilterProvider` (no DS changes)
|
||||||
|
- Filter changes = replaceState, navigation = pushState
|
||||||
|
- Copy Link button with Ctrl+Shift+C shortcut
|
||||||
|
- 9-step incremental rollout
|
||||||
|
|
||||||
|
### #104 Log Search
|
||||||
|
- New `GET /api/v1/logs/search` endpoint with cursor pagination and level counts
|
||||||
|
- 4th tab: Logs, with search bar, level filter toggles, virtual-scrolled results
|
||||||
|
- Search syntax: free text + field:value (level, app, logger, exchange, mdc.*)
|
||||||
|
- Live tail via adaptive polling (2-5s)
|
||||||
|
- Bidirectional exchange correlation
|
||||||
@@ -11,8 +11,81 @@ export interface LogEntryResponse {
|
|||||||
message: string;
|
message: string;
|
||||||
threadName: string | null;
|
threadName: string | null;
|
||||||
stackTrace: string | null;
|
stackTrace: string | null;
|
||||||
|
exchangeId: string | null;
|
||||||
|
instanceId: string | null;
|
||||||
|
application: string | null;
|
||||||
|
mdc: Record<string, string> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LogSearchPageResponse {
|
||||||
|
data: LogEntryResponse[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
levelCounts: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogSearchParams {
|
||||||
|
q?: string;
|
||||||
|
level?: string;
|
||||||
|
application?: string;
|
||||||
|
agentId?: string;
|
||||||
|
exchangeId?: string;
|
||||||
|
logger?: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
sort?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLogs(params: LogSearchParams): Promise<LogSearchPageResponse> {
|
||||||
|
const token = useAuthStore.getState().accessToken;
|
||||||
|
const urlParams = new URLSearchParams();
|
||||||
|
if (params.q) urlParams.set('q', params.q);
|
||||||
|
if (params.level) urlParams.set('level', params.level);
|
||||||
|
if (params.application) urlParams.set('application', params.application);
|
||||||
|
if (params.agentId) urlParams.set('agentId', params.agentId);
|
||||||
|
if (params.exchangeId) urlParams.set('exchangeId', params.exchangeId);
|
||||||
|
if (params.logger) urlParams.set('logger', params.logger);
|
||||||
|
if (params.from) urlParams.set('from', params.from);
|
||||||
|
if (params.to) urlParams.set('to', params.to);
|
||||||
|
if (params.cursor) urlParams.set('cursor', params.cursor);
|
||||||
|
if (params.limit) urlParams.set('limit', String(params.limit));
|
||||||
|
if (params.sort) urlParams.set('sort', params.sort);
|
||||||
|
|
||||||
|
const res = await fetch(`${config.apiBaseUrl}/logs?${urlParams}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'X-Cameleer-Protocol-Version': '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to load logs');
|
||||||
|
return res.json() as Promise<LogSearchPageResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary log search hook with cursor pagination and level counts.
|
||||||
|
*/
|
||||||
|
export function useLogs(
|
||||||
|
params: LogSearchParams,
|
||||||
|
options?: { enabled?: boolean; refetchInterval?: number | false },
|
||||||
|
) {
|
||||||
|
const defaultRefetch = useRefreshInterval(15_000);
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['logs', params],
|
||||||
|
queryFn: () => fetchLogs(params),
|
||||||
|
enabled: options?.enabled ?? true,
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
refetchInterval: options?.refetchInterval ?? defaultRefetch,
|
||||||
|
staleTime: 300,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward-compatible wrapper for existing consumers (LogTab, AgentHealth, AgentInstance).
|
||||||
|
* Returns the same shape they expect: data is the LogEntryResponse[] (unwrapped from the page response).
|
||||||
|
*/
|
||||||
export function useApplicationLogs(
|
export function useApplicationLogs(
|
||||||
application?: string,
|
application?: string,
|
||||||
agentId?: string,
|
agentId?: string,
|
||||||
@@ -21,36 +94,31 @@ export function useApplicationLogs(
|
|||||||
const refetchInterval = useRefreshInterval(15_000);
|
const refetchInterval = useRefreshInterval(15_000);
|
||||||
const { timeRange } = useGlobalFilters();
|
const { timeRange } = useGlobalFilters();
|
||||||
const to = options?.toOverride ?? timeRange.end.toISOString();
|
const to = options?.toOverride ?? timeRange.end.toISOString();
|
||||||
// When filtering by exchangeId, skip the global time range — exchange logs are historical
|
|
||||||
const useTimeRange = !options?.exchangeId;
|
const useTimeRange = !options?.exchangeId;
|
||||||
|
|
||||||
return useQuery({
|
const params: LogSearchParams = {
|
||||||
queryKey: ['logs', application, agentId,
|
application: application || undefined,
|
||||||
|
agentId: agentId || undefined,
|
||||||
|
exchangeId: options?.exchangeId || undefined,
|
||||||
|
from: useTimeRange ? timeRange.start.toISOString() : undefined,
|
||||||
|
to: useTimeRange ? to : undefined,
|
||||||
|
limit: options?.limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['logs', 'compat', application, agentId,
|
||||||
useTimeRange ? timeRange.start.toISOString() : null,
|
useTimeRange ? timeRange.start.toISOString() : null,
|
||||||
useTimeRange ? to : null,
|
useTimeRange ? to : null,
|
||||||
options?.limit, options?.exchangeId],
|
options?.limit, options?.exchangeId],
|
||||||
queryFn: async () => {
|
queryFn: () => fetchLogs(params),
|
||||||
const token = useAuthStore.getState().accessToken;
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.set('application', application!);
|
|
||||||
if (agentId) params.set('agentId', agentId);
|
|
||||||
if (options?.exchangeId) params.set('exchangeId', options.exchangeId);
|
|
||||||
if (useTimeRange) {
|
|
||||||
params.set('from', timeRange.start.toISOString());
|
|
||||||
params.set('to', to);
|
|
||||||
}
|
|
||||||
if (options?.limit) params.set('limit', String(options.limit));
|
|
||||||
const res = await fetch(`${config.apiBaseUrl}/logs?${params}`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'X-Cameleer-Protocol-Version': '1',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('Failed to load application logs');
|
|
||||||
return res.json() as Promise<LogEntryResponse[]>;
|
|
||||||
},
|
|
||||||
enabled: !!application,
|
enabled: !!application,
|
||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
refetchInterval,
|
refetchInterval,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Unwrap: existing consumers expect data to be LogEntryResponse[] directly
|
||||||
|
return {
|
||||||
|
...query,
|
||||||
|
data: query.data?.data ?? (undefined as LogEntryResponse[] | undefined),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const TABS = [
|
|||||||
{ label: 'Exchanges', value: 'exchanges' },
|
{ label: 'Exchanges', value: 'exchanges' },
|
||||||
{ label: 'Dashboard', value: 'dashboard' },
|
{ label: 'Dashboard', value: 'dashboard' },
|
||||||
{ label: 'Runtime', value: 'runtime' },
|
{ label: 'Runtime', value: 'runtime' },
|
||||||
|
{ label: 'Logs', value: 'logs' },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface ContentTabsProps {
|
interface ContentTabsProps {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { useApplicationLogs } from '../../../api/queries/logs';
|
import { useApplicationLogs } from '../../../api/queries/logs';
|
||||||
import type { LogEntryResponse } from '../../../api/queries/logs';
|
import type { LogEntryResponse } from '../../../api/queries/logs';
|
||||||
import styles from '../ExecutionDiagram.module.css';
|
import styles from '../ExecutionDiagram.module.css';
|
||||||
@@ -30,6 +31,7 @@ function formatTime(iso: string): string {
|
|||||||
|
|
||||||
export function LogTab({ applicationId, exchangeId, processorId }: LogTabProps) {
|
export function LogTab({ applicationId, exchangeId, processorId }: LogTabProps) {
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { data: logs, isLoading } = useApplicationLogs(
|
const { data: logs, isLoading } = useApplicationLogs(
|
||||||
applicationId,
|
applicationId,
|
||||||
@@ -93,23 +95,35 @@ export function LogTab({ applicationId, exchangeId, processorId }: LogTabProps)
|
|||||||
{processorId ? 'No logs for this processor' : 'No logs available'}
|
{processorId ? 'No logs for this processor' : 'No logs available'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<>
|
||||||
<tbody>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
{entries.map((entry, i) => (
|
<tbody>
|
||||||
<tr key={i} style={{ borderBottom: '1px solid var(--border-subtle)' }}>
|
{entries.map((entry, i) => (
|
||||||
<td style={{ padding: '3px 6px', whiteSpace: 'nowrap', color: 'var(--text-muted)' }}>
|
<tr key={i} style={{ borderBottom: '1px solid var(--border-subtle)' }}>
|
||||||
{formatTime(entry.timestamp)}
|
<td style={{ padding: '3px 6px', whiteSpace: 'nowrap', color: 'var(--text-muted)' }}>
|
||||||
</td>
|
{formatTime(entry.timestamp)}
|
||||||
<td style={{ padding: '3px 4px', whiteSpace: 'nowrap', fontWeight: 600, color: levelColor(entry.level), width: '40px' }}>
|
</td>
|
||||||
{entry.level}
|
<td style={{ padding: '3px 4px', whiteSpace: 'nowrap', fontWeight: 600, color: levelColor(entry.level), width: '40px' }}>
|
||||||
</td>
|
{entry.level}
|
||||||
<td style={{ padding: '3px 6px', color: 'var(--text-primary)', wordBreak: 'break-word' }}>
|
</td>
|
||||||
{entry.message}
|
<td style={{ padding: '3px 6px', color: 'var(--text-primary)', wordBreak: 'break-word' }}>
|
||||||
</td>
|
{entry.message}
|
||||||
</tr>
|
</td>
|
||||||
))}
|
</tr>
|
||||||
</tbody>
|
))}
|
||||||
</table>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{exchangeId && (
|
||||||
|
<div style={{ padding: '6px 10px', borderTop: '1px solid var(--border-subtle)', fontSize: '11px', textAlign: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/logs/${applicationId}?exchangeId=${exchangeId}`)}
|
||||||
|
style={{ background: 'none', border: 'none', color: 'var(--amber)', cursor: 'pointer', fontSize: '11px', fontFamily: 'var(--font-body)' }}
|
||||||
|
>
|
||||||
|
Open in Logs tab {'\u2192'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
import { useParams, useNavigate, useLocation } from 'react-router';
|
import { useParams, useNavigate, useLocation } from 'react-router';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
export type TabKey = 'exchanges' | 'dashboard' | 'runtime';
|
export type TabKey = 'exchanges' | 'dashboard' | 'runtime' | 'logs';
|
||||||
|
|
||||||
const VALID_TABS = new Set<TabKey>(['exchanges', 'dashboard', 'runtime']);
|
const VALID_TABS = new Set<TabKey>(['exchanges', 'dashboard', 'runtime', 'logs']);
|
||||||
|
|
||||||
export interface Scope {
|
export interface Scope {
|
||||||
tab: TabKey;
|
tab: TabKey;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { GitBranch, Server, RotateCcw } from 'lucide-react';
|
import { GitBranch, Server, RotateCcw, FileText } from 'lucide-react';
|
||||||
import { StatusDot, MonoText, Badge } from '@cameleer/design-system';
|
import { StatusDot, MonoText, Badge } from '@cameleer/design-system';
|
||||||
import { useCorrelationChain } from '../../api/queries/correlation';
|
import { useCorrelationChain } from '../../api/queries/correlation';
|
||||||
import { useAgents } from '../../api/queries/agents';
|
import { useAgents } from '../../api/queries/agents';
|
||||||
@@ -100,6 +100,13 @@ export function ExchangeHeader({ detail, onCorrelatedSelect, onClearSelection }:
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className={styles.duration}>{formatDuration(detail.durationMs)}</span>
|
<span className={styles.duration}>{formatDuration(detail.durationMs)}</span>
|
||||||
|
<button
|
||||||
|
className={styles.linkBtn}
|
||||||
|
onClick={() => navigate(`/logs/${detail.applicationId}?exchangeId=${detail.exchangeId}`)}
|
||||||
|
title="View surrounding logs"
|
||||||
|
>
|
||||||
|
<FileText size={12} className={styles.icon} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Route control / replay — only if agent supports it AND user has operator+ role */}
|
{/* Route control / replay — only if agent supports it AND user has operator+ role */}
|
||||||
|
|||||||
50
ui/src/pages/LogsTab/LevelFilterBar.tsx
Normal file
50
ui/src/pages/LogsTab/LevelFilterBar.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { ButtonGroup } from '@cameleer/design-system';
|
||||||
|
import type { ButtonGroupItem } from '@cameleer/design-system';
|
||||||
|
|
||||||
|
function formatCount(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_ITEMS: ButtonGroupItem[] = [
|
||||||
|
{ value: 'TRACE', label: 'Trace', color: 'var(--text-muted)' },
|
||||||
|
{ value: 'DEBUG', label: 'Debug', color: 'var(--running)' },
|
||||||
|
{ value: 'INFO', label: 'Info', color: 'var(--success)' },
|
||||||
|
{ value: 'WARN', label: 'Warn', color: 'var(--warning)' },
|
||||||
|
{ value: 'ERROR', label: 'Error', color: 'var(--error)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface LevelFilterBarProps {
|
||||||
|
activeLevels: Set<string>;
|
||||||
|
onChange: (levels: Set<string>) => void;
|
||||||
|
levelCounts: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LevelFilterBar({ activeLevels, onChange, levelCounts }: LevelFilterBarProps) {
|
||||||
|
const items = LEVEL_ITEMS.map((item) => ({
|
||||||
|
...item,
|
||||||
|
label: `${item.label} ${formatCount(levelCounts[item.value] ?? 0)}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<ButtonGroup items={items} value={activeLevels} onChange={onChange} />
|
||||||
|
{activeLevels.size > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => onChange(new Set())}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontFamily: 'var(--font-body)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
ui/src/pages/LogsTab/LogEntry.module.css
Normal file
187
ui/src/pages/LogsTab/LogEntry.module.css
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
.entry {
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp {
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logger {
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-width: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:hover {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
padding: 8px 12px 12px 60px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 70px 1fr;
|
||||||
|
gap: 2px 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailLabel {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailValue {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullMessage {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stackTrace {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--error);
|
||||||
|
background: var(--bg-deep);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdcSection {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdcGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdcEntry {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
background: var(--bg-deep);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdcKey {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdcValue {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBtn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionBtn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkBtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--amber);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
134
ui/src/pages/LogsTab/LogEntry.tsx
Normal file
134
ui/src/pages/LogsTab/LogEntry.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { Badge } from '@cameleer/design-system';
|
||||||
|
import type { LogEntryResponse } from '../../api/queries/logs';
|
||||||
|
import styles from './LogEntry.module.css';
|
||||||
|
|
||||||
|
function levelColor(level: string): string {
|
||||||
|
switch (level?.toUpperCase()) {
|
||||||
|
case 'ERROR': return 'var(--error)';
|
||||||
|
case 'WARN': return 'var(--warning)';
|
||||||
|
case 'INFO': return 'var(--success)';
|
||||||
|
case 'DEBUG': return 'var(--running)';
|
||||||
|
case 'TRACE': return 'var(--text-muted)';
|
||||||
|
default: return 'var(--text-secondary)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const h = String(d.getHours()).padStart(2, '0');
|
||||||
|
const m = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
const s = String(d.getSeconds()).padStart(2, '0');
|
||||||
|
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
||||||
|
return `${h}:${m}:${s}.${ms}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function abbreviateLogger(name: string | null): string {
|
||||||
|
if (!name) return '';
|
||||||
|
const parts = name.split('.');
|
||||||
|
if (parts.length <= 2) return name;
|
||||||
|
return parts.slice(0, -1).map((p) => p[0]).join('.') + '.' + parts[parts.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text: string, max: number): string {
|
||||||
|
return text.length > max ? text.slice(0, max) + '\u2026' : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogEntryProps {
|
||||||
|
entry: LogEntryResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogEntry({ entry }: LogEntryProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const hasStack = !!entry.stackTrace;
|
||||||
|
const hasExchange = !!entry.exchangeId;
|
||||||
|
|
||||||
|
const handleViewExchange = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!entry.exchangeId || !entry.application) return;
|
||||||
|
const routeId = entry.mdc?.['camel.routeId'] || '_';
|
||||||
|
navigate(`/exchanges/${entry.application}/${routeId}/${entry.exchangeId}`);
|
||||||
|
}, [entry, navigate]);
|
||||||
|
|
||||||
|
const handleCopyMessage = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await navigator.clipboard.writeText(entry.message);
|
||||||
|
}, [entry.message]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.entry} ${expanded ? styles.expanded : ''}`} onClick={() => setExpanded(!expanded)}>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<span className={styles.timestamp}>{formatTime(entry.timestamp)}</span>
|
||||||
|
<span className={styles.level} style={{ color: levelColor(entry.level) }}>{entry.level}</span>
|
||||||
|
{entry.application && <Badge label={entry.application} color="auto" />}
|
||||||
|
<span className={styles.logger} title={entry.loggerName ?? ''}>
|
||||||
|
{abbreviateLogger(entry.loggerName)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.message}>{truncate(entry.message, 200)}</span>
|
||||||
|
<span className={styles.chips}>
|
||||||
|
{hasStack && <span className={styles.chip}>Stack</span>}
|
||||||
|
{hasExchange && (
|
||||||
|
<span className={styles.chip} onClick={handleViewExchange}>Exchange</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className={styles.detail}>
|
||||||
|
<div className={styles.detailGrid}>
|
||||||
|
<span className={styles.detailLabel}>Logger</span>
|
||||||
|
<span className={styles.detailValue}>{entry.loggerName}</span>
|
||||||
|
<span className={styles.detailLabel}>Thread</span>
|
||||||
|
<span className={styles.detailValue}>{entry.threadName}</span>
|
||||||
|
<span className={styles.detailLabel}>Instance</span>
|
||||||
|
<span className={styles.detailValue}>{entry.instanceId}</span>
|
||||||
|
{hasExchange && (
|
||||||
|
<>
|
||||||
|
<span className={styles.detailLabel}>Exchange</span>
|
||||||
|
<span className={styles.detailValue}>
|
||||||
|
<button className={styles.linkBtn} onClick={handleViewExchange}>
|
||||||
|
{entry.exchangeId}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.fullMessage}>{entry.message}</div>
|
||||||
|
|
||||||
|
{hasStack && (
|
||||||
|
<pre className={styles.stackTrace}>{entry.stackTrace}</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entry.mdc && Object.keys(entry.mdc).length > 0 && (
|
||||||
|
<div className={styles.mdcSection}>
|
||||||
|
<span className={styles.detailLabel}>MDC</span>
|
||||||
|
<div className={styles.mdcGrid}>
|
||||||
|
{Object.entries(entry.mdc).map(([k, v]) => (
|
||||||
|
<div key={k} className={styles.mdcEntry}>
|
||||||
|
<span className={styles.mdcKey}>{k}</span>
|
||||||
|
<span className={styles.mdcValue}>{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
{hasExchange && (
|
||||||
|
<button className={styles.actionBtn} onClick={handleViewExchange}>
|
||||||
|
View Exchange
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className={styles.actionBtn} onClick={handleCopyMessage}>
|
||||||
|
Copy Message
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
ui/src/pages/LogsTab/LogSearch.module.css
Normal file
156
ui/src/pages/LogsTab/LogSearch.module.css
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--bg-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-deep);
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput:focus {
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchInput::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liveTailBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-deep);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liveTailBtn:hover {
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liveTailActive {
|
||||||
|
border-color: var(--success);
|
||||||
|
color: var(--success);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liveDot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--success);
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingWrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadMore {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadMoreBtn {
|
||||||
|
padding: 6px 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadMoreBtn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadMoreBtn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.newEntries {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--amber);
|
||||||
|
color: var(--bg-deep);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 4px 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fetchDot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--amber);
|
||||||
|
animation: pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scope {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
222
ui/src/pages/LogsTab/LogSearch.tsx
Normal file
222
ui/src/pages/LogsTab/LogSearch.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
import { Spinner, EmptyState, useGlobalFilters } from '@cameleer/design-system';
|
||||||
|
import { useLogs } from '../../api/queries/logs';
|
||||||
|
import { useRefreshInterval } from '../../api/queries/use-refresh-interval';
|
||||||
|
import { LevelFilterBar } from './LevelFilterBar';
|
||||||
|
import { LogEntry } from './LogEntry';
|
||||||
|
import styles from './LogSearch.module.css';
|
||||||
|
|
||||||
|
interface LogSearchProps {
|
||||||
|
defaultApplication?: string;
|
||||||
|
defaultRouteId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogSearch({ defaultApplication, defaultRouteId }: LogSearchProps) {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const { timeRange } = useGlobalFilters();
|
||||||
|
|
||||||
|
// Initialize from URL params (for cross-navigation)
|
||||||
|
const urlExchangeId = searchParams.get('exchangeId') ?? undefined;
|
||||||
|
const urlQ = searchParams.get('q') ?? undefined;
|
||||||
|
|
||||||
|
const [query, setQuery] = useState(urlQ ?? '');
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState(urlQ ?? '');
|
||||||
|
const [activeLevels, setActiveLevels] = useState<Set<string>>(new Set());
|
||||||
|
const [liveTail, setLiveTail] = useState(false);
|
||||||
|
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||||
|
const [allEntries, setAllEntries] = useState<any[]>([]);
|
||||||
|
|
||||||
|
const liveTailRef = useRef(liveTail);
|
||||||
|
liveTailRef.current = liveTail;
|
||||||
|
|
||||||
|
// Debounce search query
|
||||||
|
const debounceTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
const handleQueryChange = useCallback((value: string) => {
|
||||||
|
setQuery(value);
|
||||||
|
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
||||||
|
debounceTimer.current = setTimeout(() => {
|
||||||
|
setDebouncedQuery(value);
|
||||||
|
setCursor(undefined);
|
||||||
|
setAllEntries([]);
|
||||||
|
}, 300);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset pagination when filters change
|
||||||
|
const handleLevelChange = useCallback((levels: Set<string>) => {
|
||||||
|
setActiveLevels(levels);
|
||||||
|
setCursor(undefined);
|
||||||
|
setAllEntries([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const levelCsv = useMemo(() =>
|
||||||
|
activeLevels.size > 0 ? [...activeLevels].join(',') : undefined,
|
||||||
|
[activeLevels]);
|
||||||
|
|
||||||
|
// Build search params
|
||||||
|
const latestTsRef = useRef<string | undefined>(undefined);
|
||||||
|
const liveRefetch = useRefreshInterval(2_000);
|
||||||
|
|
||||||
|
const searchParamsObj = useMemo(() => ({
|
||||||
|
q: debouncedQuery || undefined,
|
||||||
|
level: levelCsv,
|
||||||
|
application: defaultApplication,
|
||||||
|
exchangeId: urlExchangeId,
|
||||||
|
from: liveTail
|
||||||
|
? (latestTsRef.current ?? timeRange.start.toISOString())
|
||||||
|
: timeRange.start.toISOString(),
|
||||||
|
to: liveTail ? new Date().toISOString() : timeRange.end.toISOString(),
|
||||||
|
cursor: liveTail ? undefined : cursor,
|
||||||
|
limit: liveTail ? 200 : 100,
|
||||||
|
sort: liveTail ? 'asc' as const : 'desc' as const,
|
||||||
|
}), [debouncedQuery, levelCsv, defaultApplication, urlExchangeId,
|
||||||
|
timeRange, cursor, liveTail]);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching } = useLogs(searchParamsObj, {
|
||||||
|
refetchInterval: liveTail ? liveRefetch : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live tail: append new entries
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || !liveTail) return;
|
||||||
|
if (data.data.length > 0) {
|
||||||
|
setAllEntries((prev) => {
|
||||||
|
const combined = [...prev, ...data.data];
|
||||||
|
// Buffer limit: keep last 5000
|
||||||
|
return combined.length > 5000 ? combined.slice(-5000) : combined;
|
||||||
|
});
|
||||||
|
latestTsRef.current = data.data[data.data.length - 1].timestamp;
|
||||||
|
}
|
||||||
|
}, [data, liveTail]);
|
||||||
|
|
||||||
|
// Auto-scroll for live tail
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (liveTail && autoScroll && scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [allEntries, liveTail, autoScroll]);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
if (!scrollRef.current || !liveTail) return;
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
||||||
|
setAutoScroll(scrollHeight - scrollTop - clientHeight < 50);
|
||||||
|
}, [liveTail]);
|
||||||
|
|
||||||
|
const handleToggleLiveTail = useCallback(() => {
|
||||||
|
setLiveTail((prev) => {
|
||||||
|
if (!prev) {
|
||||||
|
// Entering live tail
|
||||||
|
setAllEntries([]);
|
||||||
|
setCursor(undefined);
|
||||||
|
latestTsRef.current = undefined;
|
||||||
|
setAutoScroll(true);
|
||||||
|
}
|
||||||
|
return !prev;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLoadMore = useCallback(() => {
|
||||||
|
if (data?.nextCursor) {
|
||||||
|
setCursor(data.nextCursor);
|
||||||
|
}
|
||||||
|
}, [data?.nextCursor]);
|
||||||
|
|
||||||
|
// Accumulate pages for non-live mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (liveTail || !data) return;
|
||||||
|
if (cursor) {
|
||||||
|
// Appending a new page
|
||||||
|
setAllEntries((prev) => [...prev, ...data.data]);
|
||||||
|
} else {
|
||||||
|
// Fresh search
|
||||||
|
setAllEntries(data.data);
|
||||||
|
}
|
||||||
|
}, [data, cursor, liveTail]);
|
||||||
|
|
||||||
|
const entries = liveTail ? allEntries : allEntries;
|
||||||
|
const levelCounts = data?.levelCounts ?? {};
|
||||||
|
const hasMore = data?.hasMore ?? false;
|
||||||
|
const newEntriesCount = liveTail && !autoScroll && data?.data.length
|
||||||
|
? data.data.length : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<div className={styles.searchRow}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search logs..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => handleQueryChange(e.target.value)}
|
||||||
|
className={styles.searchInput}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={`${styles.liveTailBtn} ${liveTail ? styles.liveTailActive : ''}`}
|
||||||
|
onClick={handleToggleLiveTail}
|
||||||
|
>
|
||||||
|
{liveTail && <span className={styles.liveDot} />}
|
||||||
|
{liveTail ? 'LIVE TAIL' : 'Live Tail: OFF'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<LevelFilterBar
|
||||||
|
activeLevels={activeLevels}
|
||||||
|
onChange={handleLevelChange}
|
||||||
|
levelCounts={levelCounts}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={styles.results}
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
{isLoading && entries.length === 0 ? (
|
||||||
|
<div className={styles.loadingWrap}>
|
||||||
|
<Spinner size="md" />
|
||||||
|
</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No logs found"
|
||||||
|
description={debouncedQuery || activeLevels.size > 0
|
||||||
|
? 'Try adjusting your search or filters.'
|
||||||
|
: 'No log entries in the selected time range.'}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{entries.map((entry, i) => (
|
||||||
|
<LogEntry key={`${entry.timestamp}-${i}`} entry={entry} />
|
||||||
|
))}
|
||||||
|
{!liveTail && hasMore && (
|
||||||
|
<div className={styles.loadMore}>
|
||||||
|
<button
|
||||||
|
className={styles.loadMoreBtn}
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
|
{isFetching ? 'Loading...' : 'Load more'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{liveTail && !autoScroll && newEntriesCount > 0 && (
|
||||||
|
<div className={styles.newEntries} onClick={() => setAutoScroll(true)}>
|
||||||
|
New entries arriving — click to scroll to bottom
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.statusBar}>
|
||||||
|
<span>{entries.length} entries{liveTail ? ' (live)' : ''}</span>
|
||||||
|
{isFetching && <span className={styles.fetchDot} />}
|
||||||
|
{defaultApplication && (
|
||||||
|
<span className={styles.scope}>App: {defaultApplication}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
ui/src/pages/LogsTab/LogsPage.tsx
Normal file
7
ui/src/pages/LogsTab/LogsPage.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { useParams } from 'react-router';
|
||||||
|
import { LogSearch } from './LogSearch';
|
||||||
|
|
||||||
|
export default function LogsPage() {
|
||||||
|
const { appId, routeId } = useParams<{ appId?: string; routeId?: string }>();
|
||||||
|
return <LogSearch defaultApplication={appId} defaultRouteId={routeId} />;
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ const OidcConfigPage = lazy(() => import('./pages/Admin/OidcConfigPage'));
|
|||||||
const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage'));
|
const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage'));
|
||||||
const ClickHouseAdminPage = lazy(() => import('./pages/Admin/ClickHouseAdminPage'));
|
const ClickHouseAdminPage = lazy(() => import('./pages/Admin/ClickHouseAdminPage'));
|
||||||
const AppConfigPage = lazy(() => import('./pages/Admin/AppConfigPage'));
|
const AppConfigPage = lazy(() => import('./pages/Admin/AppConfigPage'));
|
||||||
|
const LogsPage = lazy(() => import('./pages/LogsTab/LogsPage'));
|
||||||
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
|
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
|
||||||
|
|
||||||
function SuspenseWrapper({ children }: { children: React.ReactNode }) {
|
function SuspenseWrapper({ children }: { children: React.ReactNode }) {
|
||||||
@@ -68,6 +69,11 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'runtime/:appId', element: <SuspenseWrapper><RuntimePage /></SuspenseWrapper> },
|
{ path: 'runtime/:appId', element: <SuspenseWrapper><RuntimePage /></SuspenseWrapper> },
|
||||||
{ path: 'runtime/:appId/:instanceId', element: <SuspenseWrapper><RuntimePage /></SuspenseWrapper> },
|
{ path: 'runtime/:appId/:instanceId', element: <SuspenseWrapper><RuntimePage /></SuspenseWrapper> },
|
||||||
|
|
||||||
|
// Logs tab
|
||||||
|
{ path: 'logs', element: <SuspenseWrapper><LogsPage /></SuspenseWrapper> },
|
||||||
|
{ path: 'logs/:appId', element: <SuspenseWrapper><LogsPage /></SuspenseWrapper> },
|
||||||
|
{ path: 'logs/:appId/:routeId', element: <SuspenseWrapper><LogsPage /></SuspenseWrapper> },
|
||||||
|
|
||||||
// Legacy redirects — Sidebar uses hardcoded /apps/... and /agents/... paths
|
// Legacy redirects — Sidebar uses hardcoded /apps/... and /agents/... paths
|
||||||
{ path: 'apps', element: <Navigate to="/exchanges" replace /> },
|
{ path: 'apps', element: <Navigate to="/exchanges" replace /> },
|
||||||
{ path: 'apps/:appId', element: <LegacyAppRedirect /> },
|
{ path: 'apps/:appId', element: <LegacyAppRedirect /> },
|
||||||
|
|||||||
Reference in New Issue
Block a user