feat: add Logs tab with cursor-paginated search, level filters, and live tail
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m3s
CI / docker (push) Successful in 1m11s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 49s

- Extend GET /api/v1/logs with cursor pagination, multi-level filtering,
  optional application scoping, and level count aggregation
- Add exchangeId, instanceId, application, mdc fields to log responses
- Refactor ClickHouseLogStore with keyset pagination (N+1 pattern)
- Add LogSearchRequest/LogSearchResponse core domain records
- Create LogSearchPageResponse wrapper DTO
- Add Logs as 4th content tab (Exchanges | Dashboard | Runtime | Logs)
- Implement LogSearch component with debounced search, level filter bar,
  expandable log entries, cursor pagination, and live tail mode
- Add cross-navigation: exchange header → logs, log tab → logs tab
- Update ClickHouseLogStoreIT with cursor, multi-level, cross-app tests

Closes: #104

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-02 08:47:16 +02:00
parent a52751da1b
commit b73f5e6dd4
22 changed files with 1405 additions and 119 deletions

View File

@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router';
import { GitBranch, Server, RotateCcw } from 'lucide-react';
import { GitBranch, Server, RotateCcw, FileText } from 'lucide-react';
import { StatusDot, MonoText, Badge } from '@cameleer/design-system';
import { useCorrelationChain } from '../../api/queries/correlation';
import { useAgents } from '../../api/queries/agents';
@@ -100,6 +100,13 @@ export function ExchangeHeader({ detail, onCorrelatedSelect, onClearSelection }:
</>
)}
<span className={styles.duration}>{formatDuration(detail.durationMs)}</span>
<button
className={styles.linkBtn}
onClick={() => navigate(`/logs/${detail.applicationId}?exchangeId=${detail.exchangeId}`)}
title="View surrounding logs"
>
<FileText size={12} className={styles.icon} />
</button>
</div>
{/* Route control / replay — only if agent supports it AND user has operator+ role */}

View File

@@ -0,0 +1,50 @@
import { ButtonGroup } from '@cameleer/design-system';
import type { ButtonGroupItem } from '@cameleer/design-system';
function formatCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
const LEVEL_ITEMS: ButtonGroupItem[] = [
{ value: 'TRACE', label: 'Trace', color: 'var(--text-muted)' },
{ value: 'DEBUG', label: 'Debug', color: 'var(--running)' },
{ value: 'INFO', label: 'Info', color: 'var(--success)' },
{ value: 'WARN', label: 'Warn', color: 'var(--warning)' },
{ value: 'ERROR', label: 'Error', color: 'var(--error)' },
];
interface LevelFilterBarProps {
activeLevels: Set<string>;
onChange: (levels: Set<string>) => void;
levelCounts: Record<string, number>;
}
export function LevelFilterBar({ activeLevels, onChange, levelCounts }: LevelFilterBarProps) {
const items = LEVEL_ITEMS.map((item) => ({
...item,
label: `${item.label} ${formatCount(levelCounts[item.value] ?? 0)}`,
}));
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<ButtonGroup items={items} value={activeLevels} onChange={onChange} />
{activeLevels.size > 0 && (
<button
onClick={() => onChange(new Set())}
style={{
background: 'none',
border: 'none',
color: 'var(--text-muted)',
cursor: 'pointer',
fontSize: '12px',
fontFamily: 'var(--font-body)',
}}
>
Clear
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,187 @@
.entry {
border-bottom: 1px solid var(--border-subtle);
cursor: pointer;
transition: background 0.1s;
}
.entry:hover {
background: var(--bg-hover);
}
.expanded {
background: var(--bg-surface);
}
.row {
display: flex;
align-items: baseline;
gap: 8px;
padding: 6px 12px;
font-size: 12px;
font-family: var(--font-mono);
min-height: 28px;
}
.timestamp {
color: var(--text-muted);
white-space: nowrap;
flex-shrink: 0;
}
.level {
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
min-width: 40px;
}
.logger {
color: var(--text-muted);
white-space: nowrap;
flex-shrink: 0;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 11px;
}
.message {
color: var(--text-primary);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chips {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.chip {
font-size: 10px;
padding: 1px 6px;
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-secondary);
cursor: pointer;
font-family: var(--font-body);
}
.chip:hover {
background: var(--border);
}
.detail {
padding: 8px 12px 12px 60px;
font-size: 12px;
}
.detailGrid {
display: grid;
grid-template-columns: 70px 1fr;
gap: 2px 8px;
margin-bottom: 8px;
}
.detailLabel {
color: var(--text-muted);
font-size: 11px;
}
.detailValue {
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 11px;
word-break: break-all;
}
.fullMessage {
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
margin-bottom: 8px;
padding: 8px;
background: var(--bg-deep);
border-radius: var(--radius-sm);
}
.stackTrace {
font-family: var(--font-mono);
font-size: 11px;
color: var(--error);
background: var(--bg-deep);
border-radius: var(--radius-sm);
padding: 8px;
margin: 8px 0;
overflow-x: auto;
white-space: pre;
max-height: 300px;
overflow-y: auto;
}
.mdcSection {
margin-top: 8px;
}
.mdcGrid {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.mdcEntry {
display: flex;
gap: 2px;
font-size: 11px;
font-family: var(--font-mono);
background: var(--bg-deep);
border-radius: var(--radius-sm);
padding: 2px 6px;
}
.mdcKey {
color: var(--text-muted);
}
.mdcValue {
color: var(--text-primary);
}
.actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.actionBtn {
background: none;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 4px 10px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
font-family: var(--font-body);
}
.actionBtn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.linkBtn {
background: none;
border: none;
padding: 0;
color: var(--amber);
cursor: pointer;
font-family: var(--font-mono);
font-size: 11px;
text-decoration: underline;
}

View File

@@ -0,0 +1,134 @@
import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router';
import { Badge } from '@cameleer/design-system';
import type { LogEntryResponse } from '../../api/queries/logs';
import styles from './LogEntry.module.css';
function levelColor(level: string): string {
switch (level?.toUpperCase()) {
case 'ERROR': return 'var(--error)';
case 'WARN': return 'var(--warning)';
case 'INFO': return 'var(--success)';
case 'DEBUG': return 'var(--running)';
case 'TRACE': return 'var(--text-muted)';
default: return 'var(--text-secondary)';
}
}
function formatTime(iso: string): string {
const d = new Date(iso);
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
const s = String(d.getSeconds()).padStart(2, '0');
const ms = String(d.getMilliseconds()).padStart(3, '0');
return `${h}:${m}:${s}.${ms}`;
}
function abbreviateLogger(name: string | null): string {
if (!name) return '';
const parts = name.split('.');
if (parts.length <= 2) return name;
return parts.slice(0, -1).map((p) => p[0]).join('.') + '.' + parts[parts.length - 1];
}
function truncate(text: string, max: number): string {
return text.length > max ? text.slice(0, max) + '\u2026' : text;
}
interface LogEntryProps {
entry: LogEntryResponse;
}
export function LogEntry({ entry }: LogEntryProps) {
const [expanded, setExpanded] = useState(false);
const navigate = useNavigate();
const hasStack = !!entry.stackTrace;
const hasExchange = !!entry.exchangeId;
const handleViewExchange = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (!entry.exchangeId || !entry.application) return;
const routeId = entry.mdc?.['camel.routeId'] || '_';
navigate(`/exchanges/${entry.application}/${routeId}/${entry.exchangeId}`);
}, [entry, navigate]);
const handleCopyMessage = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
await navigator.clipboard.writeText(entry.message);
}, [entry.message]);
return (
<div className={`${styles.entry} ${expanded ? styles.expanded : ''}`} onClick={() => setExpanded(!expanded)}>
<div className={styles.row}>
<span className={styles.timestamp}>{formatTime(entry.timestamp)}</span>
<span className={styles.level} style={{ color: levelColor(entry.level) }}>{entry.level}</span>
{entry.application && <Badge label={entry.application} color="auto" />}
<span className={styles.logger} title={entry.loggerName ?? ''}>
{abbreviateLogger(entry.loggerName)}
</span>
<span className={styles.message}>{truncate(entry.message, 200)}</span>
<span className={styles.chips}>
{hasStack && <span className={styles.chip}>Stack</span>}
{hasExchange && (
<span className={styles.chip} onClick={handleViewExchange}>Exchange</span>
)}
</span>
</div>
{expanded && (
<div className={styles.detail}>
<div className={styles.detailGrid}>
<span className={styles.detailLabel}>Logger</span>
<span className={styles.detailValue}>{entry.loggerName}</span>
<span className={styles.detailLabel}>Thread</span>
<span className={styles.detailValue}>{entry.threadName}</span>
<span className={styles.detailLabel}>Instance</span>
<span className={styles.detailValue}>{entry.instanceId}</span>
{hasExchange && (
<>
<span className={styles.detailLabel}>Exchange</span>
<span className={styles.detailValue}>
<button className={styles.linkBtn} onClick={handleViewExchange}>
{entry.exchangeId}
</button>
</span>
</>
)}
</div>
<div className={styles.fullMessage}>{entry.message}</div>
{hasStack && (
<pre className={styles.stackTrace}>{entry.stackTrace}</pre>
)}
{entry.mdc && Object.keys(entry.mdc).length > 0 && (
<div className={styles.mdcSection}>
<span className={styles.detailLabel}>MDC</span>
<div className={styles.mdcGrid}>
{Object.entries(entry.mdc).map(([k, v]) => (
<div key={k} className={styles.mdcEntry}>
<span className={styles.mdcKey}>{k}</span>
<span className={styles.mdcValue}>{v}</span>
</div>
))}
</div>
</div>
)}
<div className={styles.actions}>
{hasExchange && (
<button className={styles.actionBtn} onClick={handleViewExchange}>
View Exchange
</button>
)}
<button className={styles.actionBtn} onClick={handleCopyMessage}>
Copy Message
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,156 @@
.container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--bg-body);
}
.toolbar {
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 8px;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-surface);
}
.searchRow {
display: flex;
gap: 8px;
align-items: center;
}
.searchInput {
flex: 1;
padding: 6px 10px;
font-size: 13px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--bg-deep);
color: var(--text-primary);
outline: none;
font-family: var(--font-mono);
}
.searchInput:focus {
border-color: var(--amber);
}
.searchInput::placeholder {
color: var(--text-muted);
}
.liveTailBtn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--bg-deep);
color: var(--text-secondary);
cursor: pointer;
font-family: var(--font-body);
white-space: nowrap;
}
.liveTailBtn:hover {
border-color: var(--border);
}
.liveTailActive {
border-color: var(--success);
color: var(--success);
background: var(--bg-surface);
}
.liveDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.results {
flex: 1;
overflow-y: auto;
position: relative;
}
.loadingWrap {
display: flex;
justify-content: center;
padding: 3rem;
}
.loadMore {
display: flex;
justify-content: center;
padding: 12px;
}
.loadMoreBtn {
padding: 6px 20px;
font-size: 12px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--bg-surface);
color: var(--text-secondary);
cursor: pointer;
font-family: var(--font-body);
}
.loadMoreBtn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.loadMoreBtn:disabled {
opacity: 0.5;
cursor: default;
}
.newEntries {
position: sticky;
bottom: 0;
text-align: center;
padding: 8px;
background: var(--amber);
color: var(--bg-deep);
font-size: 12px;
cursor: pointer;
font-weight: 500;
}
.statusBar {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 16px;
font-size: 11px;
color: var(--text-muted);
border-top: 1px solid var(--border-subtle);
background: var(--bg-surface);
font-family: var(--font-mono);
}
.fetchDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--amber);
animation: pulse 1s ease-in-out infinite;
}
.scope {
margin-left: auto;
}

View File

@@ -0,0 +1,222 @@
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { useSearchParams } from 'react-router';
import { Spinner, EmptyState, useGlobalFilters } from '@cameleer/design-system';
import { useLogs } from '../../api/queries/logs';
import { useRefreshInterval } from '../../api/queries/use-refresh-interval';
import { LevelFilterBar } from './LevelFilterBar';
import { LogEntry } from './LogEntry';
import styles from './LogSearch.module.css';
interface LogSearchProps {
defaultApplication?: string;
defaultRouteId?: string;
}
export function LogSearch({ defaultApplication, defaultRouteId }: LogSearchProps) {
const [searchParams] = useSearchParams();
const { timeRange } = useGlobalFilters();
// Initialize from URL params (for cross-navigation)
const urlExchangeId = searchParams.get('exchangeId') ?? undefined;
const urlQ = searchParams.get('q') ?? undefined;
const [query, setQuery] = useState(urlQ ?? '');
const [debouncedQuery, setDebouncedQuery] = useState(urlQ ?? '');
const [activeLevels, setActiveLevels] = useState<Set<string>>(new Set());
const [liveTail, setLiveTail] = useState(false);
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [allEntries, setAllEntries] = useState<any[]>([]);
const liveTailRef = useRef(liveTail);
liveTailRef.current = liveTail;
// Debounce search query
const debounceTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleQueryChange = useCallback((value: string) => {
setQuery(value);
if (debounceTimer.current) clearTimeout(debounceTimer.current);
debounceTimer.current = setTimeout(() => {
setDebouncedQuery(value);
setCursor(undefined);
setAllEntries([]);
}, 300);
}, []);
// Reset pagination when filters change
const handleLevelChange = useCallback((levels: Set<string>) => {
setActiveLevels(levels);
setCursor(undefined);
setAllEntries([]);
}, []);
const levelCsv = useMemo(() =>
activeLevels.size > 0 ? [...activeLevels].join(',') : undefined,
[activeLevels]);
// Build search params
const latestTsRef = useRef<string | undefined>(undefined);
const liveRefetch = useRefreshInterval(2_000);
const searchParamsObj = useMemo(() => ({
q: debouncedQuery || undefined,
level: levelCsv,
application: defaultApplication,
exchangeId: urlExchangeId,
from: liveTail
? (latestTsRef.current ?? timeRange.start.toISOString())
: timeRange.start.toISOString(),
to: liveTail ? new Date().toISOString() : timeRange.end.toISOString(),
cursor: liveTail ? undefined : cursor,
limit: liveTail ? 200 : 100,
sort: liveTail ? 'asc' as const : 'desc' as const,
}), [debouncedQuery, levelCsv, defaultApplication, urlExchangeId,
timeRange, cursor, liveTail]);
const { data, isLoading, isFetching } = useLogs(searchParamsObj, {
refetchInterval: liveTail ? liveRefetch : undefined,
});
// Live tail: append new entries
useEffect(() => {
if (!data || !liveTail) return;
if (data.data.length > 0) {
setAllEntries((prev) => {
const combined = [...prev, ...data.data];
// Buffer limit: keep last 5000
return combined.length > 5000 ? combined.slice(-5000) : combined;
});
latestTsRef.current = data.data[data.data.length - 1].timestamp;
}
}, [data, liveTail]);
// Auto-scroll for live tail
const scrollRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
useEffect(() => {
if (liveTail && autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [allEntries, liveTail, autoScroll]);
const handleScroll = useCallback(() => {
if (!scrollRef.current || !liveTail) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
setAutoScroll(scrollHeight - scrollTop - clientHeight < 50);
}, [liveTail]);
const handleToggleLiveTail = useCallback(() => {
setLiveTail((prev) => {
if (!prev) {
// Entering live tail
setAllEntries([]);
setCursor(undefined);
latestTsRef.current = undefined;
setAutoScroll(true);
}
return !prev;
});
}, []);
const handleLoadMore = useCallback(() => {
if (data?.nextCursor) {
setCursor(data.nextCursor);
}
}, [data?.nextCursor]);
// Accumulate pages for non-live mode
useEffect(() => {
if (liveTail || !data) return;
if (cursor) {
// Appending a new page
setAllEntries((prev) => [...prev, ...data.data]);
} else {
// Fresh search
setAllEntries(data.data);
}
}, [data, cursor, liveTail]);
const entries = liveTail ? allEntries : allEntries;
const levelCounts = data?.levelCounts ?? {};
const hasMore = data?.hasMore ?? false;
const newEntriesCount = liveTail && !autoScroll && data?.data.length
? data.data.length : 0;
return (
<div className={styles.container}>
<div className={styles.toolbar}>
<div className={styles.searchRow}>
<input
type="text"
placeholder="Search logs..."
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
className={styles.searchInput}
/>
<button
className={`${styles.liveTailBtn} ${liveTail ? styles.liveTailActive : ''}`}
onClick={handleToggleLiveTail}
>
{liveTail && <span className={styles.liveDot} />}
{liveTail ? 'LIVE TAIL' : 'Live Tail: OFF'}
</button>
</div>
<LevelFilterBar
activeLevels={activeLevels}
onChange={handleLevelChange}
levelCounts={levelCounts}
/>
</div>
<div
className={styles.results}
ref={scrollRef}
onScroll={handleScroll}
>
{isLoading && entries.length === 0 ? (
<div className={styles.loadingWrap}>
<Spinner size="md" />
</div>
) : entries.length === 0 ? (
<EmptyState
title="No logs found"
description={debouncedQuery || activeLevels.size > 0
? 'Try adjusting your search or filters.'
: 'No log entries in the selected time range.'}
/>
) : (
<>
{entries.map((entry, i) => (
<LogEntry key={`${entry.timestamp}-${i}`} entry={entry} />
))}
{!liveTail && hasMore && (
<div className={styles.loadMore}>
<button
className={styles.loadMoreBtn}
onClick={handleLoadMore}
disabled={isFetching}
>
{isFetching ? 'Loading...' : 'Load more'}
</button>
</div>
)}
</>
)}
{liveTail && !autoScroll && newEntriesCount > 0 && (
<div className={styles.newEntries} onClick={() => setAutoScroll(true)}>
New entries arriving click to scroll to bottom
</div>
)}
</div>
<div className={styles.statusBar}>
<span>{entries.length} entries{liveTail ? ' (live)' : ''}</span>
{isFetching && <span className={styles.fetchDot} />}
{defaultApplication && (
<span className={styles.scope}>App: {defaultApplication}</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,7 @@
import { useParams } from 'react-router';
import { LogSearch } from './LogSearch';
export default function LogsPage() {
const { appId, routeId } = useParams<{ appId?: string; routeId?: string }>();
return <LogSearch defaultApplication={appId} defaultRouteId={routeId} />;
}