feat: exchange-level log viewer on ExchangeDetail page
All checks were successful
CI / build (push) Successful in 1m0s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 49s
CI / deploy (push) Successful in 37s
CI / deploy-feature (push) Has been skipped

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:
hsiegeln
2026-03-26 10:26:30 +01:00
parent f9bd492191
commit ea665ff411
5 changed files with 190 additions and 6 deletions

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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));

View File

@@ -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;
}

View File

@@ -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">
&times;
</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>
)
}