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'}
)}