From b612941aaeaba349b978fdd97a22e70b4e1f9fc5 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:56:13 +0100 Subject: [PATCH] feat: wire up application logs from OpenSearch, fix event autoscroll Add GET /api/v1/logs endpoint to query application logs stored in OpenSearch with filters for application, agent, level, time range, and text search. Wire up the AgentInstance LogViewer with real data and an EventFeed-style toolbar (search input + level filter pills). Fix agent events timeline autoscroll by reversing the DESC-ordered events so newest entries appear at the bottom where EventFeed autoscrolls to. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/controller/LogQueryController.java | 49 ++++++++++++ .../server/app/dto/LogEntryResponse.java | 13 +++ .../server/app/search/OpenSearchLogIndex.java | 62 +++++++++++++++ ui/src/api/queries/logs.ts | 46 +++++++++++ ui/src/pages/AgentHealth/AgentHealth.tsx | 2 +- .../AgentInstance/AgentInstance.module.css | 63 +++++++++++++++ ui/src/pages/AgentInstance/AgentInstance.tsx | 79 +++++++++++++++---- 7 files changed, 299 insertions(+), 15 deletions(-) create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LogQueryController.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/LogEntryResponse.java create mode 100644 ui/src/api/queries/logs.ts diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LogQueryController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LogQueryController.java new file mode 100644 index 00000000..5985d646 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LogQueryController.java @@ -0,0 +1,49 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.server.app.dto.LogEntryResponse; +import com.cameleer3.server.app.search.OpenSearchLogIndex; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; +import java.util.List; + +@RestController +@RequestMapping("/api/v1/logs") +@Tag(name = "Application Logs", description = "Query application logs stored in OpenSearch") +public class LogQueryController { + + private final OpenSearchLogIndex logIndex; + + public LogQueryController(OpenSearchLogIndex logIndex) { + this.logIndex = logIndex; + } + + @GetMapping + @Operation(summary = "Search application log entries", + description = "Returns log entries for a given application, optionally filtered by agent, level, time range, and text query") + public ResponseEntity> searchLogs( + @RequestParam String application, + @RequestParam(required = false) String agentId, + @RequestParam(required = false) String level, + @RequestParam(required = false) String query, + @RequestParam(required = false) String from, + @RequestParam(required = false) String to, + @RequestParam(defaultValue = "200") int limit) { + + limit = Math.min(limit, 1000); + + Instant fromInstant = from != null ? Instant.parse(from) : null; + Instant toInstant = to != null ? Instant.parse(to) : null; + + List entries = logIndex.search( + application, agentId, level, query, fromInstant, toInstant, limit); + + return ResponseEntity.ok(entries); + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/LogEntryResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/LogEntryResponse.java new file mode 100644 index 00000000..b373a311 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/LogEntryResponse.java @@ -0,0 +1,13 @@ +package com.cameleer3.server.app.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Application log entry from OpenSearch") +public record LogEntryResponse( + @Schema(description = "Log timestamp (ISO-8601)") String timestamp, + @Schema(description = "Log level (INFO, WARN, ERROR, DEBUG)") String level, + @Schema(description = "Logger name") String loggerName, + @Schema(description = "Log message") String message, + @Schema(description = "Thread name") String threadName, + @Schema(description = "Stack trace (if present)") String stackTrace +) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/OpenSearchLogIndex.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/OpenSearchLogIndex.java index ea81544c..d9a1e6a6 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/OpenSearchLogIndex.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/OpenSearchLogIndex.java @@ -1,9 +1,15 @@ package com.cameleer3.server.app.search; import com.cameleer3.common.model.LogEntry; +import com.cameleer3.server.app.dto.LogEntryResponse; import jakarta.annotation.PostConstruct; +import org.opensearch.client.json.JsonData; import org.opensearch.client.opensearch.OpenSearchClient; +import org.opensearch.client.opensearch._types.FieldValue; +import org.opensearch.client.opensearch._types.SortOrder; import org.opensearch.client.opensearch._types.mapping.Property; +import org.opensearch.client.opensearch._types.query_dsl.BoolQuery; +import org.opensearch.client.opensearch._types.query_dsl.Query; import org.opensearch.client.opensearch.core.BulkRequest; import org.opensearch.client.opensearch.core.BulkResponse; import org.opensearch.client.opensearch.core.bulk.BulkResponseItem; @@ -15,8 +21,10 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Repository; import java.io.IOException; +import java.time.Instant; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -91,6 +99,60 @@ public class OpenSearchLogIndex { } } + public List search(String application, String agentId, String level, + String query, Instant from, Instant to, int limit) { + try { + BoolQuery.Builder bool = new BoolQuery.Builder(); + bool.must(Query.of(q -> q.term(t -> t.field("application").value(FieldValue.of(application))))); + if (agentId != null && !agentId.isEmpty()) { + bool.must(Query.of(q -> q.term(t -> t.field("agentId").value(FieldValue.of(agentId))))); + } + if (level != null && !level.isEmpty()) { + bool.must(Query.of(q -> q.term(t -> t.field("level").value(FieldValue.of(level.toUpperCase()))))); + } + if (query != null && !query.isEmpty()) { + bool.must(Query.of(q -> q.match(m -> m.field("message").query(FieldValue.of(query))))); + } + if (from != null || to != null) { + bool.must(Query.of(q -> q.range(r -> { + r.field("@timestamp"); + if (from != null) r.gte(JsonData.of(from.toString())); + if (to != null) r.lte(JsonData.of(to.toString())); + return r; + }))); + } + + var response = client.search(s -> s + .index(indexPrefix + "*") + .query(Query.of(q -> q.bool(bool.build()))) + .sort(so -> so.field(f -> f.field("@timestamp").order(SortOrder.Desc))) + .size(limit), Map.class); + + List results = new ArrayList<>(); + for (var hit : response.hits().hits()) { + @SuppressWarnings("unchecked") + Map src = (Map) hit.source(); + if (src == null) continue; + results.add(new LogEntryResponse( + str(src, "@timestamp"), + str(src, "level"), + str(src, "loggerName"), + str(src, "message"), + str(src, "threadName"), + str(src, "stackTrace"))); + } + return results; + } catch (IOException e) { + log.error("Failed to search log entries for application={}", application, e); + return List.of(); + } + } + + private static String str(Map map, String key) { + Object v = map.get(key); + return v != null ? v.toString() : null; + } + public void indexBatch(String agentId, String application, List entries) { if (entries == null || entries.isEmpty()) { return; diff --git a/ui/src/api/queries/logs.ts b/ui/src/api/queries/logs.ts new file mode 100644 index 00000000..7ece5dd7 --- /dev/null +++ b/ui/src/api/queries/logs.ts @@ -0,0 +1,46 @@ +import { useQuery } from '@tanstack/react-query'; +import { config } from '../../config'; +import { useAuthStore } from '../../auth/auth-store'; +import { useRefreshInterval } from './use-refresh-interval'; +import { useGlobalFilters } from '@cameleer/design-system'; + +export interface LogEntryResponse { + timestamp: string; + level: string; + loggerName: string | null; + message: string; + threadName: string | null; + stackTrace: string | null; +} + +export function useApplicationLogs( + application?: string, + agentId?: string, + options?: { limit?: number }, +) { + const refetchInterval = useRefreshInterval(15_000); + const { timeRange } = useGlobalFilters(); + + return useQuery({ + queryKey: ['logs', application, agentId, timeRange.start.toISOString(), timeRange.end.toISOString(), options?.limit], + queryFn: async () => { + const token = useAuthStore.getState().accessToken; + const params = new URLSearchParams(); + params.set('application', application!); + if (agentId) params.set('agentId', agentId); + params.set('from', timeRange.start.toISOString()); + params.set('to', timeRange.end.toISOString()); + 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; + }, + enabled: !!application, + refetchInterval, + }); +} diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index 0d76b27c..96c5aa93 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -256,7 +256,7 @@ export default function AgentHealth() { : ('running' as const), message: `${e.agentId}: ${e.eventType}${e.detail ? ' \u2014 ' + e.detail : ''}`, timestamp: new Date(e.timestamp), - })), + })).toReversed(), [events], ); diff --git a/ui/src/pages/AgentInstance/AgentInstance.module.css b/ui/src/pages/AgentInstance/AgentInstance.module.css index 74ea6911..f1ec0d5b 100644 --- a/ui/src/pages/AgentInstance/AgentInstance.module.css +++ b/ui/src/pages/AgentInstance/AgentInstance.module.css @@ -142,6 +142,69 @@ border-bottom: 1px solid var(--border-subtle); } +.logToolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-surface); +} + +.logSearchWrap { + position: relative; + flex: 1; + min-width: 0; +} + +.logSearchInput { + width: 100%; + padding: 5px 28px 5px 10px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: var(--bg-body); + color: var(--text-primary); + font-size: 12px; + font-family: var(--font-body); + outline: none; +} + +.logSearchInput:focus { + border-color: var(--amber); +} + +.logSearchInput::placeholder { + color: var(--text-faint); +} + +.logSearchClear { + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 14px; + padding: 0 4px; + line-height: 1; +} + +.logClearFilters { + background: none; + border: none; + color: var(--text-muted); + font-size: 11px; + cursor: pointer; + padding: 2px 6px; + white-space: nowrap; +} + +.logClearFilters:hover { + color: var(--text-primary); +} + /* Empty state (shared) */ .logEmpty { padding: 24px; diff --git a/ui/src/pages/AgentInstance/AgentInstance.tsx b/ui/src/pages/AgentInstance/AgentInstance.tsx index bd609ce1..2004ec5f 100644 --- a/ui/src/pages/AgentInstance/AgentInstance.tsx +++ b/ui/src/pages/AgentInstance/AgentInstance.tsx @@ -3,24 +3,36 @@ import { useParams, Link } from 'react-router'; import { StatCard, StatusDot, Badge, LineChart, AreaChart, BarChart, EventFeed, Spinner, EmptyState, SectionHeader, MonoText, - LogViewer, Tabs, useGlobalFilters, + LogViewer, ButtonGroup, useGlobalFilters, } from '@cameleer/design-system'; -import type { FeedEvent, LogEntry } from '@cameleer/design-system'; +import type { FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/design-system'; import styles from './AgentInstance.module.css'; import { useAgents, useAgentEvents } from '../../api/queries/agents'; +import { useApplicationLogs } from '../../api/queries/logs'; import { useStatsTimeseries } from '../../api/queries/executions'; import { useAgentMetrics } from '../../api/queries/agent-metrics'; -const LOG_TABS = [ - { label: 'All', value: 'all' }, - { label: 'Warnings', value: 'warn' }, - { label: 'Errors', value: 'error' }, +const LOG_LEVEL_ITEMS: ButtonGroupItem[] = [ + { value: 'error', label: 'Error', color: 'var(--error)' }, + { value: 'warn', label: 'Warn', color: 'var(--warning)' }, + { value: 'info', label: 'Info', color: 'var(--success)' }, + { value: 'debug', label: 'Debug', color: 'var(--running)' }, ]; +function mapLogLevel(level: string): LogEntry['level'] { + switch (level?.toUpperCase()) { + case 'ERROR': return 'error'; + case 'WARN': case 'WARNING': return 'warn'; + case 'DEBUG': case 'TRACE': return 'debug'; + default: return 'info'; + } +} + export default function AgentInstance() { const { appId, instanceId } = useParams(); const { timeRange } = useGlobalFilters(); - const [logFilter, setLogFilter] = useState('all'); + const [logSearch, setLogSearch] = useState(''); + const [logLevels, setLogLevels] = useState>(new Set()); const timeFrom = timeRange.start.toISOString(); const timeTo = timeRange.end.toISOString(); @@ -78,7 +90,8 @@ export default function AgentInstance() { : ('running' as const), message: `${e.eventType}${e.detail ? ' \u2014 ' + e.detail : ''}`, timestamp: new Date(e.timestamp), - })), + })) + .toReversed(), [events, instanceId], ); @@ -123,10 +136,20 @@ export default function AgentInstance() { [chartData], ); - // Placeholder log entries (backend does not stream logs yet) - const logEntries = useMemo(() => [], []); - const filteredLogs = - logFilter === 'all' ? logEntries : logEntries.filter((l) => l.level === logFilter); + // Application logs from OpenSearch + const { data: rawLogs } = useApplicationLogs(appId, instanceId); + const logEntries = useMemo( + () => (rawLogs || []).map((l) => ({ + timestamp: l.timestamp ?? '', + level: mapLogLevel(l.level), + message: l.message ?? '', + })), + [rawLogs], + ); + const searchLower = logSearch.toLowerCase(); + const filteredLogs = logEntries + .filter((l) => logLevels.size === 0 || logLevels.has(l.level)) + .filter((l) => !searchLower || l.message.toLowerCase().includes(searchLower)); if (isLoading) return ; @@ -374,13 +397,41 @@ export default function AgentInstance() {
Application Log - + {logEntries.length} entries +
+
+
+ setLogSearch(e.target.value)} + aria-label="Search logs" + /> + {logSearch && ( + + )} +
+ + {logLevels.size > 0 && ( + + )}
{filteredLogs.length > 0 ? ( ) : (
- Application log streaming is not yet available. + {logSearch || logLevels.size > 0 ? 'No matching log entries' : 'No log entries available'}
)}