feat: exchange-level log viewer on ExchangeDetail page
Index exchangeId from Camel MDC (camel.exchangeId) as a top-level keyword field in OpenSearch log indices. Add exchangeId filter to the log query API and frontend hook. Show a LogViewer on the ExchangeDetail page filtered to that exchange's logs, with search input and level filter pills. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<LogEntryResponse> entries = logIndex.search(
|
||||
application, agentId, level, query, fromInstant, toInstant, limit);
|
||||
application, agentId, level, query, exchangeId, fromInstant, toInstant, limit);
|
||||
|
||||
return ResponseEntity.ok(entries);
|
||||
}
|
||||
|
||||
@@ -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<LogEntryResponse> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Set<string>>(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<LogEntry[]>(
|
||||
() => (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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Exchange Application Log */}
|
||||
{detail && (
|
||||
<div className={styles.logSection}>
|
||||
<div className={styles.logSectionHeader}>
|
||||
<SectionHeader>Application Log</SectionHeader>
|
||||
<span className={styles.logMeta}>{logEntries.length} entries</span>
|
||||
</div>
|
||||
<div className={styles.logToolbar}>
|
||||
<div className={styles.logSearchWrap}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.logSearchInput}
|
||||
placeholder="Search logs\u2026"
|
||||
value={logSearch}
|
||||
onChange={(e) => setLogSearch(e.target.value)}
|
||||
aria-label="Search logs"
|
||||
/>
|
||||
{logSearch && (
|
||||
<button type="button" className={styles.logSearchClear} onClick={() => setLogSearch('')} aria-label="Clear search">
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ButtonGroup items={LOG_LEVEL_ITEMS} value={logLevels} onChange={setLogLevels} />
|
||||
{logLevels.size > 0 && (
|
||||
<button className={styles.logClearFilters} onClick={() => setLogLevels(new Set())}>Clear</button>
|
||||
)}
|
||||
</div>
|
||||
{filteredLogs.length > 0 ? (
|
||||
<LogViewer entries={filteredLogs} maxHeight={360} />
|
||||
) : (
|
||||
<div className={styles.logEmpty}>
|
||||
{logSearch || logLevels.size > 0 ? 'No matching log entries' : 'No logs captured for this exchange'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user