Files
aiops/frontend/src/components/monitor/log-center/EnhancedLogResults.tsx
T
Logan 7e1c8f583c feat: 工具配置增强、站点拨测与日志中心多项改进
- 工具配置:新增卸载 API/UI、Go 工具链、流水线构建自动安装依赖
- 修复 ListTools VerifyTool 递归栈溢出导致获取工具配置失败
- Node.js Alpine 安装改用 unofficial-builds musl 预编译包与 index.json 解析 LTS
- 站点拨测:详情页路由、告警自动恢复、并发拨测与默认可观测频率
- 日志中心:RBAC、查询构建器、ES/Loki 增强与审计 request/response 展示
- 发布配置:构建节点、流水线远程执行、Deploy RBAC 导航拆分
2026-07-02 07:47:33 +08:00

454 lines
20 KiB
TypeScript

// 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<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 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<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;
}
// 日志行 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 (
<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-[180px] mt-0.5" style={{ color: 'var(--dg-text-muted)' }} title={entry.timestamp}>
{displayTs}
</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 font-mono flex-1 min-w-0 ${messageLineClass(wrap, trunc && !expanded)}`}
style={{ color: 'var(--dg-text-secondary)' }}
title={!expanded && fullText.length > 120 ? fullText : undefined}
>
{preview}
</span>
{/* 复制按钮(hover 显示,复制完整原文) */}
<button
onClick={e => { e.stopPropagation(); void copyRaw(); }}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity mt-0.5"
style={{ color: 'var(--dg-text-muted)' }}
title="复制完整日志"
>
{copied ? <Check className="h-3 w-3 dg-text-green" /> : <Copy className="h-3 w-3" />}
</button>
</div>
{/* 展开详情:完整日志 + labels + fields */}
{expanded && (
<div className="animate-fade-in" style={{ background: 'var(--dg-bg-secondary)', padding: '8px 12px 12px 36px' }}>
{/* 完整日志正文 */}
<div className="mb-3">
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] font-medium" style={{ color: 'var(--dg-text-muted)' }}>完整日志</p>
<button
type="button"
onClick={e => { e.stopPropagation(); void copyRaw(); }}
className="text-[10px] px-2 py-0.5 rounded border"
style={{ borderColor: 'var(--dg-border)', color: 'var(--dg-text-secondary)' }}
>
{copied ? '已复制' : '复制全文'}
</button>
</div>
<pre
className="text-[11px] font-mono whitespace-pre-wrap break-all p-2 rounded max-h-[320px] overflow-y-auto"
style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-primary)', border: '1px solid var(--dg-border)' }}
>
{fullText}
</pre>
</div>
{/* 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] flex-shrink-0" style={{ color: 'var(--dg-text-primary)' }}>{k}</span>
<span className="text-[11px] font-mono flex-1 break-all" 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 = formatLogTimestamp(e.timestamp);
else if (col === 'message') val = e.raw || e.message;
else val = e.labels?.[col] || '—';
return (
<td key={col} className={`px-2 py-1.5 max-w-[400px] ${wrap ? 'break-all whitespace-pre-wrap' : trunc ? 'truncate' : 'break-all'}`} style={{ color: 'var(--dg-text-secondary)' }} title={val}>
{trunc ? formatLogPreview(val, true, 200) : 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)' }}>{formatLogTimestamp(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 ${messageLineClass(wrap, trunc)} max-w-[800px]`} style={{ color: 'var(--dg-text-secondary)' }} title={e.raw || e.message}>
{trunc ? formatLogPreview(e.raw || e.message, true, 300) : (e.raw || 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>
);
}