feat(log-center): 日志中心 v4 全量组件 — 统一查询面板/时间选择器/统计图表/聚类/数据源管理
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user