// EnhancedLogResults.tsx — Grafana 风格日志结果展示 // Logan.Li import { useState, useMemo } from 'react'; import { ChevronDown, ChevronRight, BarChart3, Plus, Search, Copy, Check } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import { copyToClipboard } from '@/utils/clipboard'; import { buildLogCopyText, detectLogFields, formatLogPreview, formatLogTimestamp } from './log-format'; import type { LogEntry, ViewMode } from './types-v3'; type ViewDensity = 'compact' | 'comfortable' | 'loose'; interface Props { entries: LogEntry[]; loading?: boolean; viewMode: ViewMode; wrapLines: boolean; truncate: boolean; density?: ViewDensity; fieldSearch?: string; // Grafana 风格新增 // Logan.Li showTime?: boolean; onShowTimeChange?: (v: boolean) => void; uniqueLabels?: boolean; onUniqueLabelsChange?: (v: boolean) => void; prettifyJSON?: boolean; onPrettifyJSONChange?: (v: boolean) => void; dedupMode?: DedupMode; onDedupModeChange?: (m: DedupMode) => void; sortOrder?: 'newest' | 'oldest'; onSortOrderChange?: (o: 'newest' | 'oldest') => void; // Common labels commonLabels?: Record; // 标签操作回调 onLabelFilter?: (key: string, value: string, op: 'include' | 'exclude') => void; } type DedupMode = 'none' | 'exact' | 'numbers' | 'signature'; function levelBadge(level?: string): string { const l = (level || '').toLowerCase(); if (l === 'error' || l === 'fatal' || l === 'err') return 'bg-red-500/15 dg-text-red border-red-500/30'; if (l === 'warn' || l === 'warning') return 'bg-amber-500/15 dg-text-yellow border-amber-500/30'; if (l === 'info') return 'bg-emerald-500/15 dg-text-green border-emerald-500/30'; if (l === 'debug') return 'bg-[var(--dg-bg-active)]/15 dg-text-secondary border-[var(--dg-border)]'; return 'bg-[var(--dg-bg-active)]/15 dg-text-secondary border-[var(--dg-border)]'; } function levelColorText(level?: string): string { const l = (level || '').toLowerCase(); if (l === 'error' || l === 'fatal' || l === 'err') return '#f87171'; if (l === 'warn' || l === 'warning') return '#fbbf24'; if (l === 'info') return '#34d399'; if (l === 'debug') return '#94a3b8'; return '#94a3b8'; } const DENSITY_PADDING: Record = { compact: 'py-0.5', comfortable: 'py-1.5', loose: 'py-2.5', }; // ===== Dedup 处理 ===== // function dedupEntries(entries: LogEntry[], mode: DedupMode): LogEntry[] { if (mode === 'none') return entries; const seen = new Set(); return entries.filter(e => { let key: string; if (mode === 'exact') key = e.message; else if (mode === 'numbers') key = e.message.replace(/\d+/g, ''); else key = `${e.level || ''}:${e.labels?.service || ''}:${e.message.slice(0, 80)}`; if (seen.has(key)) return false; seen.add(key); return true; }); } // ===== 提取 Common Labels ===== // export function extractCommonLabels(entries: LogEntry[]): Record | null { if (entries.length === 0) return null; const first = entries[0].labels || {}; const common: Record = {}; for (const [k, v] of Object.entries(first)) { if (entries.every(e => e.labels?.[k] === v)) common[k] = v; } return Object.keys(common).length > 0 ? common : null; } // 日志行 CSS:截断关闭时用横向滚动而非省略号,避免看不到后半段 // Logan.Li function messageLineClass(wrap: boolean, trunc: boolean): string { if (wrap) return 'whitespace-pre-wrap break-all'; if (trunc) return 'truncate'; return 'whitespace-nowrap overflow-x-auto'; } // ===== 原始日志视图(Grafana 风格)===== function RawLogView({ entry, wrap, trunc, showTime, onLabelFilter, }: { entry: LogEntry; wrap: boolean; trunc: boolean; showTime: boolean; onLabelFilter?: (k: string, v: string, op: 'include' | 'exclude') => void; }) { const { toast } = useToast(); const [expanded, setExpanded] = useState(false); const [copied, setCopied] = useState(false); const labels = entry.labels || {}; const fullText = entry.raw || entry.message || ''; const detected = useMemo(() => detectLogFields(fullText), [fullText]); const displayTs = formatLogTimestamp(entry.timestamp); const preview = expanded ? fullText : formatLogPreview(fullText, trunc); const copyRaw = async () => { const text = buildLogCopyText(entry); const ok = await copyToClipboard(text); if (ok) { setCopied(true); toast({ title: '已复制完整日志' }); setTimeout(() => setCopied(false), 1500); } else { toast({ title: '复制失败', description: '请展开后手动选中复制', variant: 'destructive' }); } }; return (
{/* 日志行 */}
setExpanded(!expanded)} > {/* 展开图标 */} {expanded ? : } {/* 时间戳 */} {showTime && ( {displayTs} )} {/* 日志级别 */} {entry.level && ( {entry.level.toUpperCase()} )} {/* 日志内容(折叠预览) */} 120 ? fullText : undefined} > {preview} {/* 复制按钮(hover 显示,复制完整原文) */}
{/* 展开详情:完整日志 + labels + fields */} {expanded && (
{/* 完整日志正文 */}

完整日志

              {fullText}
            
{/* Log labels */} {Object.keys(labels).length > 0 && (

Log labels

{Object.entries(labels).map(([k, v]) => (
{k} {v}
))}
)} {/* Detected fields */} {detected.length > 0 && (

Detected fields

{detected.map(({ name, value }) => (
{name} {value}
))}
)}
)}
); } // ===== 字段视图 ===== // function FieldView({ entries, wrap, trunc, search }: { entries: LogEntry[]; wrap: boolean; trunc: boolean; search?: string }) { const columns = useMemo(() => { const colSet = new Set(); entries.forEach(e => { if (e.labels) Object.keys(e.labels).forEach(k => colSet.add(k)); }); colSet.add('timestamp'); colSet.add('message'); const arr = Array.from(colSet); return search ? arr.filter(c => c.toLowerCase().includes(search.toLowerCase())) : arr; }, [entries, search]); return (
{columns.map(col => ( ))} {entries.map((e, i) => ( {columns.map(col => { let val: string; if (col === 'timestamp') val = formatLogTimestamp(e.timestamp); else if (col === 'message') val = e.raw || e.message; else val = e.labels?.[col] || '—'; return ( ); })} ))}
{col}
{trunc ? formatLogPreview(val, true, 200) : val}
); } // ===== 表格视图 ===== // function TableView({ entries, wrap, trunc }: { entries: LogEntry[]; wrap: boolean; trunc: boolean }) { return (
{entries.map((e, i) => ( ))}
Time Level Message
{formatLogTimestamp(e.timestamp)} {e.level ? {e.level} : '—'} {trunc ? formatLogPreview(e.raw || e.message, true, 300) : (e.raw || e.message)}
); } // ===== 主组件 ===== // export function EnhancedLogResults({ entries, loading, viewMode, wrapLines, truncate, density = 'comfortable', showTime = true, onShowTimeChange, uniqueLabels = false, onUniqueLabelsChange, prettifyJSON = false, onPrettifyJSONChange, dedupMode = 'none', onDedupModeChange, sortOrder = 'newest', onSortOrderChange, commonLabels, onLabelFilter, }: Props) { // 去重处理 const dedupedEntries = useMemo(() => dedupEntries(entries, dedupMode), [entries, dedupMode]); // 排序处理 const sortedEntries = useMemo(() => { if (sortOrder === 'oldest') return [...dedupedEntries].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); return dedupedEntries; }, [dedupedEntries, sortOrder]); if (loading) { return (
{[1, 2, 3, 4, 5].map(i => (
))}
); } if (entries.length === 0) { return (
暂无日志
); } return (
{/* Common labels */} {commonLabels && Object.keys(commonLabels).length > 0 && (
Common labels: {Object.entries(commonLabels).map(([k, v]) => ( {k}={v} ))}
)} {/* 工具栏 */}
{/* Time 开关 */} {/* Unique labels */} {/* Wrap lines */} {/* Prettify JSON */} {/* Dedup */}
Dedup {(['none', 'exact', 'numbers', 'signature'] as DedupMode[]).map(m => ( ))}
{/* Sort order */}
Display results {(['newest', 'oldest'] as const).map(o => ( ))}
{/* 结果内容 */}
{viewMode === 'raw' && sortedEntries.map((e, i) => ( ))} {viewMode === 'field' && } {viewMode === 'table' && }
); }