feat: add Logs tab with cursor-paginated search, level filters, and live tail
- 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:
134
ui/src/pages/LogsTab/LogEntry.tsx
Normal file
134
ui/src/pages/LogsTab/LogEntry.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user