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 agentId,
|
||||||
@RequestParam(required = false) String level,
|
@RequestParam(required = false) String level,
|
||||||
@RequestParam(required = false) String query,
|
@RequestParam(required = false) String query,
|
||||||
|
@RequestParam(required = false) String exchangeId,
|
||||||
@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(defaultValue = "200") int limit) {
|
||||||
@@ -42,7 +43,7 @@ public class LogQueryController {
|
|||||||
Instant toInstant = to != null ? Instant.parse(to) : null;
|
Instant toInstant = to != null ? Instant.parse(to) : null;
|
||||||
|
|
||||||
List<LogEntryResponse> entries = logIndex.search(
|
List<LogEntryResponse> entries = logIndex.search(
|
||||||
application, agentId, level, query, fromInstant, toInstant, limit);
|
application, agentId, level, query, exchangeId, fromInstant, toInstant, limit);
|
||||||
|
|
||||||
return ResponseEntity.ok(entries);
|
return ResponseEntity.ok(entries);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ public class OpenSearchLogIndex {
|
|||||||
.properties("threadName", Property.of(p -> p.keyword(k -> k)))
|
.properties("threadName", Property.of(p -> p.keyword(k -> k)))
|
||||||
.properties("stackTrace", Property.of(p -> p.text(tx -> tx)))
|
.properties("stackTrace", Property.of(p -> p.text(tx -> tx)))
|
||||||
.properties("agentId", Property.of(p -> p.keyword(k -> k)))
|
.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);
|
log.info("OpenSearch log index template '{}' created", templateName);
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
@@ -100,13 +101,17 @@ public class OpenSearchLogIndex {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<LogEntryResponse> search(String application, String agentId, String level,
|
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 {
|
try {
|
||||||
BoolQuery.Builder bool = new BoolQuery.Builder();
|
BoolQuery.Builder bool = new BoolQuery.Builder();
|
||||||
bool.must(Query.of(q -> q.term(t -> t.field("application").value(FieldValue.of(application)))));
|
bool.must(Query.of(q -> q.term(t -> t.field("application").value(FieldValue.of(application)))));
|
||||||
if (agentId != null && !agentId.isEmpty()) {
|
if (agentId != null && !agentId.isEmpty()) {
|
||||||
bool.must(Query.of(q -> q.term(t -> t.field("agentId").value(FieldValue.of(agentId)))));
|
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()) {
|
if (level != null && !level.isEmpty()) {
|
||||||
bool.must(Query.of(q -> q.term(t -> t.field("level").value(FieldValue.of(level.toUpperCase())))));
|
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("mdc", entry.getMdc());
|
||||||
doc.put("agentId", agentId);
|
doc.put("agentId", agentId);
|
||||||
doc.put("application", application);
|
doc.put("application", application);
|
||||||
|
if (entry.getMdc() != null) {
|
||||||
|
String exId = entry.getMdc().get("camel.exchangeId");
|
||||||
|
if (exId != null) doc.put("exchangeId", exId);
|
||||||
|
}
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,19 +16,20 @@ export interface LogEntryResponse {
|
|||||||
export function useApplicationLogs(
|
export function useApplicationLogs(
|
||||||
application?: string,
|
application?: string,
|
||||||
agentId?: string,
|
agentId?: string,
|
||||||
options?: { limit?: number; toOverride?: string },
|
options?: { limit?: number; toOverride?: string; exchangeId?: string },
|
||||||
) {
|
) {
|
||||||
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();
|
||||||
|
|
||||||
return useQuery({
|
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 () => {
|
queryFn: async () => {
|
||||||
const token = useAuthStore.getState().accessToken;
|
const token = useAuthStore.getState().accessToken;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('application', application!);
|
params.set('application', application!);
|
||||||
if (agentId) params.set('agentId', agentId);
|
if (agentId) params.set('agentId', agentId);
|
||||||
|
if (options?.exchangeId) params.set('exchangeId', options.exchangeId);
|
||||||
params.set('from', timeRange.start.toISOString());
|
params.set('from', timeRange.start.toISOString());
|
||||||
params.set('to', to);
|
params.set('to', to);
|
||||||
if (options?.limit) params.set('limit', String(options.limit));
|
if (options?.limit) params.set('limit', String(options.limit));
|
||||||
|
|||||||
@@ -448,3 +448,99 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px;
|
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 {
|
import {
|
||||||
Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
|
Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
|
||||||
ProcessorTimeline, Breadcrumb, Spinner, RouteFlow, useToast,
|
ProcessorTimeline, Breadcrumb, Spinner, RouteFlow, useToast,
|
||||||
|
LogViewer, ButtonGroup, SectionHeader,
|
||||||
} from '@cameleer/design-system'
|
} 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 { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'
|
||||||
import { useCorrelationChain } from '../../api/queries/correlation'
|
import { useCorrelationChain } from '../../api/queries/correlation'
|
||||||
import { useDiagramLayout } from '../../api/queries/diagrams'
|
import { useDiagramLayout } from '../../api/queries/diagrams'
|
||||||
import { mapDiagramToRouteNodes, toFlowSegments } from '../../utils/diagram-mapping'
|
import { mapDiagramToRouteNodes, toFlowSegments } from '../../utils/diagram-mapping'
|
||||||
import { useTracingStore } from '../../stores/tracing-store'
|
import { useTracingStore } from '../../stores/tracing-store'
|
||||||
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands'
|
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands'
|
||||||
|
import { useApplicationLogs } from '../../api/queries/logs'
|
||||||
import styles from './ExchangeDetail.module.css'
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
function formatDuration(ms: number): string {
|
function formatDuration(ms: number): string {
|
||||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
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 { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null)
|
||||||
|
|
||||||
const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt')
|
const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt')
|
||||||
|
const [logSearch, setLogSearch] = useState('')
|
||||||
|
const [logLevels, setLogLevels] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const procList = detail
|
const procList = detail
|
||||||
? (detail.processors?.length ? detail.processors : (detail.children ?? []))
|
? (detail.processors?.length ? detail.processors : (detail.children ?? []))
|
||||||
@@ -236,6 +256,25 @@ export default function ExchangeDetail() {
|
|||||||
return correlationData.data
|
return correlationData.data
|
||||||
}, [correlationData])
|
}, [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 ────────────────────────────────────────────────────────
|
// ── Loading state ────────────────────────────────────────────────────────
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -585,6 +624,44 @@ export default function ExchangeDetail() {
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user