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 index 5985d646..5def039e 100644 --- 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 @@ -32,6 +32,7 @@ public class LogQueryController { @RequestParam(required = false) String agentId, @RequestParam(required = false) String level, @RequestParam(required = false) String query, + @RequestParam(required = false) String exchangeId, @RequestParam(required = false) String from, @RequestParam(required = false) String to, @RequestParam(defaultValue = "200") int limit) { @@ -42,7 +43,7 @@ public class LogQueryController { Instant toInstant = to != null ? Instant.parse(to) : null; List entries = logIndex.search( - application, agentId, level, query, fromInstant, toInstant, limit); + application, agentId, level, query, exchangeId, fromInstant, toInstant, limit); return ResponseEntity.ok(entries); } 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 d9a1e6a6..a3a8de15 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 @@ -76,7 +76,8 @@ public class OpenSearchLogIndex { .properties("threadName", Property.of(p -> p.keyword(k -> k))) .properties("stackTrace", Property.of(p -> p.text(tx -> tx))) .properties("agentId", Property.of(p -> p.keyword(k -> k))) - .properties("application", Property.of(p -> p.keyword(k -> k))))))); + .properties("application", Property.of(p -> p.keyword(k -> k))) + .properties("exchangeId", Property.of(p -> p.keyword(k -> k))))))); log.info("OpenSearch log index template '{}' created", templateName); } } catch (IOException e) { @@ -100,13 +101,17 @@ public class OpenSearchLogIndex { } public List search(String application, String agentId, String level, - String query, Instant from, Instant to, int limit) { + String query, String exchangeId, + 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 (exchangeId != null && !exchangeId.isEmpty()) { + bool.must(Query.of(q -> q.term(t -> t.field("exchangeId").value(FieldValue.of(exchangeId))))); + } if (level != null && !level.isEmpty()) { bool.must(Query.of(q -> q.term(t -> t.field("level").value(FieldValue.of(level.toUpperCase()))))); } @@ -205,6 +210,10 @@ public class OpenSearchLogIndex { doc.put("mdc", entry.getMdc()); doc.put("agentId", agentId); doc.put("application", application); + if (entry.getMdc() != null) { + String exId = entry.getMdc().get("camel.exchangeId"); + if (exId != null) doc.put("exchangeId", exId); + } return doc; } } diff --git a/ui/src/api/queries/logs.ts b/ui/src/api/queries/logs.ts index c705ee73..aa39f272 100644 --- a/ui/src/api/queries/logs.ts +++ b/ui/src/api/queries/logs.ts @@ -16,19 +16,20 @@ export interface LogEntryResponse { export function useApplicationLogs( application?: string, agentId?: string, - options?: { limit?: number; toOverride?: string }, + options?: { limit?: number; toOverride?: string; exchangeId?: string }, ) { const refetchInterval = useRefreshInterval(15_000); const { timeRange } = useGlobalFilters(); const to = options?.toOverride ?? timeRange.end.toISOString(); return useQuery({ - queryKey: ['logs', application, agentId, timeRange.start.toISOString(), to, options?.limit], + queryKey: ['logs', application, agentId, timeRange.start.toISOString(), to, options?.limit, options?.exchangeId], queryFn: async () => { 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); params.set('from', timeRange.start.toISOString()); params.set('to', to); if (options?.limit) params.set('limit', String(options.limit)); diff --git a/ui/src/pages/ExchangeDetail/ExchangeDetail.module.css b/ui/src/pages/ExchangeDetail/ExchangeDetail.module.css index 1928b04d..ee9df5ea 100644 --- a/ui/src/pages/ExchangeDetail/ExchangeDetail.module.css +++ b/ui/src/pages/ExchangeDetail/ExchangeDetail.module.css @@ -448,3 +448,99 @@ text-align: center; padding: 20px; } + +/* Exchange log section */ +.logSection { + margin-top: 20px; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + overflow: hidden; + display: flex; + flex-direction: column; + max-height: 420px; +} + +.logSectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border-subtle); +} + +.logMeta { + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.logToolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-subtle); +} + +.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); +} + +.logEmpty { + padding: 24px; + text-align: center; + color: var(--text-faint); + font-size: 12px; +} diff --git a/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx b/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx index 9e32591e..b7649da6 100644 --- a/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx +++ b/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx @@ -3,16 +3,34 @@ import { useParams, useNavigate } from 'react-router' import { Badge, StatusDot, MonoText, CodeBlock, InfoCallout, ProcessorTimeline, Breadcrumb, Spinner, RouteFlow, useToast, + LogViewer, ButtonGroup, SectionHeader, } from '@cameleer/design-system' -import type { ProcessorStep, RouteNode, NodeBadge } from '@cameleer/design-system' +import type { ProcessorStep, RouteNode, NodeBadge, LogEntry, ButtonGroupItem } from '@cameleer/design-system' import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions' import { useCorrelationChain } from '../../api/queries/correlation' import { useDiagramLayout } from '../../api/queries/diagrams' import { mapDiagramToRouteNodes, toFlowSegments } from '../../utils/diagram-mapping' import { useTracingStore } from '../../stores/tracing-store' import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands' +import { useApplicationLogs } from '../../api/queries/logs' import styles from './ExchangeDetail.module.css' +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' + } +} + // ── Helpers ────────────────────────────────────────────────────────────────── function formatDuration(ms: number): string { if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s` @@ -69,6 +87,8 @@ export default function ExchangeDetail() { const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null) const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt') + const [logSearch, setLogSearch] = useState('') + const [logLevels, setLogLevels] = useState>(new Set()) const procList = detail ? (detail.processors?.length ? detail.processors : (detail.children ?? [])) @@ -236,6 +256,25 @@ export default function ExchangeDetail() { return correlationData.data }, [correlationData]) + // Exchange logs from OpenSearch (filtered by exchangeId via MDC) + const { data: rawLogs } = useApplicationLogs( + detail?.applicationName, + undefined, + { exchangeId: detail?.exchangeId ?? undefined }, + ) + const logEntries = useMemo( + () => (rawLogs || []).map((l) => ({ + timestamp: l.timestamp ?? '', + level: mapLogLevel(l.level), + message: l.message ?? '', + })), + [rawLogs], + ) + const logSearchLower = logSearch.toLowerCase() + const filteredLogs = logEntries + .filter((l) => logLevels.size === 0 || logLevels.has(l.level)) + .filter((l) => !logSearchLower || l.message.toLowerCase().includes(logSearchLower)) + // ── Loading state ──────────────────────────────────────────────────────── if (isLoading) { return ( @@ -585,6 +624,44 @@ export default function ExchangeDetail() { )} + {/* Exchange Application Log */} + {detail && ( +
+
+ Application Log + {logEntries.length} entries +
+
+
+ setLogSearch(e.target.value)} + aria-label="Search logs" + /> + {logSearch && ( + + )} +
+ + {logLevels.size > 0 && ( + + )} +
+ {filteredLogs.length > 0 ? ( + + ) : ( +
+ {logSearch || logLevels.size > 0 ? 'No matching log entries' : 'No logs captured for this exchange'} +
+ )} +
+ )} + ) }