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

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