feat(log-center): 日志中心 v4 全量组件 — 统一查询面板/时间选择器/统计图表/聚类/数据源管理

This commit is contained in:
attacker committed 2026-06-27 11:41:50 +08:00
1 parent 880cf050fe
commit bdf3c2daf4
30 files changed
+4276 -136

No files matched your search

@@ -0,0 +1,428 @@
// EnhancedLogResults.tsx — Grafana 风格日志结果展示 // Logan.Li
import { useState, useMemo } from 'react';
import { ChevronDown, ChevronRight, BarChart3, Plus, Search, Copy, Check } from 'lucide-react';
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<string, string>;
// 标签操作回调
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 text-red-400 border-red-500/30';
if (l === 'warn' || l === 'warning') return 'bg-amber-500/15 text-amber-400 border-amber-500/30';
if (l === 'info') return 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30';
if (l === 'debug') return 'bg-slate-500/15 text-slate-400 border-slate-500/30';
return 'bg-slate-500/15 text-slate-400 border-slate-500/30';
}
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<ViewDensity, string> = {
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<string>();
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<string, string> | null {
if (entries.length === 0) return null;
const first = entries[0].labels || {};
const common: Record<string, string> = {};
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;
}
// ===== 检测结构化字段(从消息中解析)=====
function detectFields(raw: string): { name: string; value: string }[] {
const fields: { name: string; value: string }[] = [];
try {
const parsed = JSON.parse(raw);
if (typeof parsed === 'object' && parsed !== null) {
for (const [k, v] of Object.entries(parsed)) {
fields.push({ name: k, value: String(v) });
}
}
} catch {
// 非 JSON,尝试 key=value 模式
const regex = /(\w+)=([^,\s]+)/g;
let m;
while ((m = regex.exec(raw)) !== null) {
fields.push({ name: m[1], value: m[2] });
}
}
return fields;
}
// ===== 原始日志视图(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 [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const labels = entry.labels || {};
const detected = useMemo(() => detectFields(entry.raw || entry.message), [entry]);
const copyRaw = () => {
navigator.clipboard.writeText(entry.raw || entry.message).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 1500);
});
};
return (
<div className="group" style={{ borderBottom: '1px solid var(--dg-border)' }}>
{/* 日志行 */}
<div
className="flex items-start gap-2 cursor-pointer hover:opacity-90 transition-colors"
style={{ background: 'var(--dg-bg-secondary)', padding: '4px 12px' }}
onClick={() => setExpanded(!expanded)}
>
{/* 展开图标 */}
<span className="flex-shrink-0 mt-0.5" style={{ color: 'var(--dg-text-muted)' }}>
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
</span>
{/* 时间戳 */}
{showTime && (
<span className="text-[11px] font-mono flex-shrink-0 w-[140px] mt-0.5" style={{ color: 'var(--dg-text-muted)' }}>
{entry.timestamp}
</span>
)}
{/* 日志级别 */}
{entry.level && (
<span className="text-[10px] font-medium flex-shrink-0 mt-0.5" style={{ color: levelColorText(entry.level), minWidth: '40px' }}>
{entry.level.toUpperCase()}
</span>
)}
{/* 日志内容 */}
<span className={`text-xs flex-1 min-w-0 ${wrap ? 'whitespace-pre-wrap break-all' : 'truncate'}`} style={{ color: 'var(--dg-text-secondary)' }}>
{trunc && entry.message.length > 300 && !expanded ? entry.message.slice(0, 300) + '...' : entry.message}
</span>
{/* 复制按钮(hover 显示)*/}
<button
onClick={e => { e.stopPropagation(); copyRaw(); }}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity mt-0.5"
style={{ color: 'var(--dg-text-muted)' }}
>
{copied ? <Check className="h-3 w-3 text-green-400" /> : <Copy className="h-3 w-3" />}
</button>
</div>
{/* 展开详情:Log labels + Detected fields */}
{expanded && (
<div className="animate-fade-in" style={{ background: 'var(--dg-bg-secondary)', padding: '8px 12px 12px 36px' }}>
{/* Log labels */}
{Object.keys(labels).length > 0 && (
<div className="mb-3">
<p className="text-[10px] font-medium mb-1.5" style={{ color: 'var(--dg-text-muted)' }}>Log labels</p>
<div className="space-y-1">
{Object.entries(labels).map(([k, v]) => (
<div key={k} className="flex items-center gap-2 group/label">
<BarChart3 className="h-3 w-3 flex-shrink-0 opacity-0 group-hover/label:opacity-100 transition-opacity" style={{ color: 'var(--dg-text-muted)' }} />
<button
className="opacity-0 group-hover/label:opacity-100 transition-opacity flex-shrink-0"
style={{ color: '#22c55e' }}
onClick={e => { e.stopPropagation(); onLabelFilter?.(k, v, 'include'); }}
title={`Filter for ${k}=${v}`}
>
<Plus className="h-3 w-3" />
</button>
<span className="text-[11px] font-mono w-[160px] truncate flex-shrink-0" style={{ color: 'var(--dg-text-primary)' }}>{k}</span>
<span className="text-[11px] font-mono flex-1 truncate" style={{ color: 'var(--dg-text-secondary)' }}>{v}</span>
</div>
))}
</div>
</div>
)}
{/* Detected fields */}
{detected.length > 0 && (
<div>
<p className="text-[10px] font-medium mb-1.5" style={{ color: 'var(--dg-text-muted)' }}>Detected fields</p>
<div className="space-y-1">
{detected.map(({ name, value }) => (
<div key={name} className="flex items-center gap-2 group/field">
<BarChart3 className="h-3 w-3 flex-shrink-0 opacity-0 group-hover/field:opacity-100 transition-opacity" style={{ color: 'var(--dg-text-muted)' }} />
<button
className="opacity-0 group-hover/field:opacity-100 transition-opacity flex-shrink-0"
style={{ color: '#22c55e' }}
onClick={e => { e.stopPropagation(); onLabelFilter?.(name, value, 'include'); }}
>
<Plus className="h-3 w-3" />
</button>
<button
className="opacity-0 group-hover/field:opacity-100 transition-opacity flex-shrink-0"
style={{ color: '#ef4444' }}
onClick={e => { e.stopPropagation(); onLabelFilter?.(name, value, 'exclude'); }}
>
<Search className="h-3 w-3" />
</button>
<span className="text-[11px] font-mono w-[160px] truncate flex-shrink-0" style={{ color: 'var(--dg-text-primary)' }}>{name}</span>
<span className="text-[11px] font-mono flex-1 break-all" style={{ color: 'var(--dg-text-secondary)' }}>{value}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}
// ===== 字段视图 ===== //
function FieldView({ entries, wrap, trunc, search }: { entries: LogEntry[]; wrap: boolean; trunc: boolean; search?: string }) {
const columns = useMemo(() => {
const colSet = new Set<string>();
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 (
<div className="overflow-x-auto">
<table className="w-full text-xs" style={{ borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--dg-bg-tertiary)', borderBottom: '1px solid var(--dg-border)' }}>
{columns.map(col => (
<th key={col} className="text-left px-2 py-1.5 font-medium whitespace-nowrap" style={{ color: 'var(--dg-text-muted)', fontSize: '10px', textTransform: 'uppercase' }}>{col}</th>
))}
</tr>
</thead>
<tbody>
{entries.map((e, i) => (
<tr key={i} className="hover:opacity-80 transition-colors" style={{ borderBottom: '1px solid var(--dg-border)' }}>
{columns.map(col => {
let val: string;
if (col === 'timestamp') val = e.timestamp;
else if (col === 'message') val = e.message;
else val = e.labels?.[col] || '—';
return (
<td key={col} className={`px-2 py-1.5 max-w-[300px] ${!wrap ? 'truncate' : 'break-all'}`} style={{ color: 'var(--dg-text-secondary)' }}>
{trunc && val.length > 100 ? val.slice(0, 100) + '...' : val}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
// ===== 表格视图 ===== //
function TableView({ entries, wrap, trunc }: { entries: LogEntry[]; wrap: boolean; trunc: boolean }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-xs" style={{ borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--dg-bg-tertiary)', borderBottom: '1px solid var(--dg-border)' }}>
<th className="text-left px-3 py-2 font-medium w-[160px]" style={{ color: 'var(--dg-text-muted)', fontSize: '10px' }}>Time</th>
<th className="text-left px-3 py-2 font-medium w-[72px]" style={{ color: 'var(--dg-text-muted)', fontSize: '10px' }}>Level</th>
<th className="text-left px-3 py-2 font-medium" style={{ color: 'var(--dg-text-muted)', fontSize: '10px' }}>Message</th>
</tr>
</thead>
<tbody>
{entries.map((e, i) => (
<tr key={i} className="hover:opacity-80 transition-colors" style={{ borderBottom: '1px solid var(--dg-border)' }}>
<td className="px-3 py-2 font-mono text-[11px] whitespace-nowrap" style={{ color: 'var(--dg-text-muted)' }}>{e.timestamp}</td>
<td className="px-3 py-2">
{e.level ? <span className={`text-[10px] px-1.5 py-0.5 rounded border ${levelBadge(e.level)}`}>{e.level}</span> : '—'}
</td>
<td className={`px-3 py-2 ${wrap ? 'whitespace-pre-wrap break-all' : 'truncate'} max-w-[600px]`} style={{ color: 'var(--dg-text-secondary)' }}>
{trunc && e.message.length > 200 ? e.message.slice(0, 200) + '...' : e.message}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ===== 主组件 ===== //
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 (
<div className="border rounded-lg overflow-hidden animate-pulse" style={{ borderColor: 'var(--dg-border)' }}>
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className={`${DENSITY_PADDING[density]} px-3`} style={{ borderBottom: '1px solid var(--dg-border)' }}>
<div className="h-4 rounded" style={{ background: 'var(--dg-bg-tertiary)', width: `${60 + Math.random() * 40}%` }} />
</div>
))}
</div>
);
}
if (entries.length === 0) {
return (
<div className="border rounded-lg overflow-hidden flex items-center justify-center" style={{ borderColor: 'var(--dg-border)', minHeight: '200px' }}>
<span className="text-sm" style={{ color: 'var(--dg-text-muted)' }}>暂无日志</span>
</div>
);
}
return (
<div>
{/* Common labels */}
{commonLabels && Object.keys(commonLabels).length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 mb-2 px-1">
<span className="text-[11px]" style={{ color: 'var(--dg-text-muted)' }}>Common labels:</span>
{Object.entries(commonLabels).map(([k, v]) => (
<span key={k} className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-secondary)', border: '1px solid var(--dg-border)' }}>
{k}={v}
</span>
))}
</div>
)}
{/* 工具栏 */}
<div className="flex flex-wrap items-center gap-3 mb-2 px-1">
{/* Time 开关 */}
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }}>
<input type="checkbox" checked={showTime} onChange={e => onShowTimeChange?.(e.target.checked)} className="h-3 w-3 rounded" />
Time
</label>
{/* Unique labels */}
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }}>
<input type="checkbox" checked={uniqueLabels} onChange={e => onUniqueLabelsChange?.(e.target.checked)} className="h-3 w-3 rounded" />
Unique labels
</label>
{/* Wrap lines */}
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }}>
<input type="checkbox" checked={wrapLines} onChange={() => {}} className="h-3 w-3 rounded" disabled />
Wrap lines
</label>
{/* Prettify JSON */}
<label className="flex items-center gap-1.5 text-[11px] cursor-pointer" style={{ color: 'var(--dg-text-muted)' }}>
<input type="checkbox" checked={prettifyJSON} onChange={e => onPrettifyJSONChange?.(e.target.checked)} className="h-3 w-3 rounded" />
Prettify JSON
</label>
{/* Dedup */}
<div className="flex items-center gap-1 text-[11px]">
<span style={{ color: 'var(--dg-text-muted)' }}>Dedup</span>
{(['none', 'exact', 'numbers', 'signature'] as DedupMode[]).map(m => (
<button
key={m}
onClick={() => onDedupModeChange?.(m)}
className="px-1.5 py-0.5 rounded text-[10px] transition-colors"
style={{
background: dedupMode === m ? 'var(--dg-bg-tertiary)' : 'transparent',
color: dedupMode === m ? 'var(--dg-text-primary)' : 'var(--dg-text-muted)',
border: dedupMode === m ? '1px solid var(--dg-border)' : '1px solid transparent',
}}
>
{m.charAt(0).toUpperCase() + m.slice(1)}
</button>
))}
</div>
{/* Sort order */}
<div className="flex items-center gap-1 ml-auto">
<span className="text-[11px]" style={{ color: 'var(--dg-text-muted)' }}>Display results</span>
{(['newest', 'oldest'] as const).map(o => (
<button
key={o}
onClick={() => onSortOrderChange?.(o)}
className="px-2 py-0.5 rounded text-[10px] transition-colors"
style={{
background: sortOrder === o ? 'var(--dg-bg-tertiary)' : 'transparent',
color: sortOrder === o ? 'var(--dg-text-primary)' : 'var(--dg-text-muted)',
border: sortOrder === o ? '1px solid var(--dg-border)' : '1px solid transparent',
}}
>
{o === 'newest' ? 'Newest first' : 'Oldest first'}
</button>
))}
</div>
</div>
{/* 结果内容 */}
<div className="border rounded-lg overflow-hidden" style={{ borderColor: 'var(--dg-border)' }}>
{viewMode === 'raw' && sortedEntries.map((e, i) => (
<RawLogView key={i} entry={e} wrap={wrapLines} trunc={truncate} showTime={showTime} onLabelFilter={onLabelFilter} />
))}
{viewMode === 'field' && <FieldView entries={sortedEntries} wrap={wrapLines} trunc={truncate} />}
{viewMode === 'table' && <TableView entries={sortedEntries} wrap={wrapLines} trunc={truncate} />}
</div>
</div>
);
}