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

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